@@ -634,6 +634,219 @@ async fn gem_hosted_stale_archive_at_configured_cache_path_warns_and_is_not_atte
634634 }
635635}
636636
637+ /// #577: Bundler reads settings local → env → GLOBAL → default, and the
638+ /// global tier is the file `bundle config set --global …` writes:
639+ /// `$BUNDLE_CONFIG`, else `$BUNDLE_USER_CONFIG`, else
640+ /// `$BUNDLE_USER_HOME/config`, else `~/.bundle/config`. A global
641+ /// `cache_path` moves bundler's install-from cache exactly like a local
642+ /// one, so the stale archive there must warn and must not be attested.
643+ /// A local `cache_path` still beats the global one, and under
644+ /// `BUNDLE_IGNORE_CONFIG` neither file counts.
645+ #[ tokio:: test( flavor = "multi_thread" ) ]
646+ async fn gem_hosted_stale_archive_at_global_cache_path_warns_and_is_not_attested ( ) {
647+ let server = MockServer :: start ( ) . await ;
648+ mount_api ( & server, None ) . await ;
649+ let moved = "---\n BUNDLE_CACHE_PATH: \" vendor/gems\" \n " ;
650+ // (label, env var naming the global file or its dir, local config,
651+ // extra env, cache dir the archive sits in)
652+ for ( label, global_var, local, extra, cache_dir) in [
653+ ( "home" , "HOME" , None , None , "gems" ) ,
654+ ( "user-config" , "BUNDLE_USER_CONFIG" , None , None , "gems" ) ,
655+ ( "user-home" , "BUNDLE_USER_HOME" , None , None , "gems" ) ,
656+ ( "bundle-config" , "BUNDLE_CONFIG" , None , None , "gems" ) ,
657+ // The local tier beats the global one: the global `vendor/gems`
658+ // is shadowed by the local `vendor/other`.
659+ (
660+ "local-wins" ,
661+ "HOME" ,
662+ Some ( "---\n BUNDLE_CACHE_PATH: \" vendor/other\" \n " ) ,
663+ None ,
664+ "other" ,
665+ ) ,
666+ // Bundler skips every config file: `vendor/cache` stays in force.
667+ (
668+ "ignore-config" ,
669+ "HOME" ,
670+ None ,
671+ Some ( ( "BUNDLE_IGNORE_CONFIG" , "1" ) ) ,
672+ "cache" ,
673+ ) ,
674+ ] {
675+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
676+ let proj = tmp. path ( ) . join ( "proj" ) ;
677+ std:: fs:: create_dir_all ( & proj) . unwrap ( ) ;
678+ write_manifest_pair ( & proj) ;
679+ if let Some ( local) = local {
680+ std:: fs:: create_dir_all ( proj. join ( ".bundle" ) ) . unwrap ( ) ;
681+ std:: fs:: write ( proj. join ( ".bundle" ) . join ( "config" ) , local) . unwrap ( ) ;
682+ }
683+ // Lay the global config down where `global_var` points bundler.
684+ let user = tmp. path ( ) . join ( "user" ) ;
685+ let ( global_value, global_file) = match global_var {
686+ "HOME" => ( user. clone ( ) , user. join ( ".bundle" ) . join ( "config" ) ) ,
687+ "BUNDLE_USER_HOME" => ( user. clone ( ) , user. join ( "config" ) ) ,
688+ _ => ( user. join ( "bundle-config" ) , user. join ( "bundle-config" ) ) ,
689+ } ;
690+ std:: fs:: create_dir_all ( global_file. parent ( ) . unwrap ( ) ) . unwrap ( ) ;
691+ std:: fs:: write ( & global_file, moved) . unwrap ( ) ;
692+ let archive = proj
693+ . join ( "vendor" )
694+ . join ( cache_dir)
695+ . join ( format ! ( "{DEP}-{DEP_VERSION}.gem" ) ) ;
696+ std:: fs:: create_dir_all ( archive. parent ( ) . unwrap ( ) ) . unwrap ( ) ;
697+ std:: fs:: write ( & archive, b"upstream-gem-archive-bytes" ) . unwrap ( ) ;
698+
699+ let global_value = global_value. to_str ( ) . unwrap ( ) . to_string ( ) ;
700+ let mut env: Vec < ( & str , & str ) > = vec ! [ ( global_var, & global_value) ] ;
701+ // HOME keeps pointing somewhere empty for the non-HOME rows, so an
702+ // ambient ~/.bundle/config cannot leak in.
703+ let empty_home = tmp. path ( ) . join ( "empty-home" ) ;
704+ let empty_home = empty_home. to_str ( ) . unwrap ( ) . to_string ( ) ;
705+ if global_var != "HOME" {
706+ env. push ( ( "HOME" , & empty_home) ) ;
707+ }
708+ env. extend ( extra) ;
709+
710+ let vex_path = proj. join ( "out.vex.json" ) ;
711+ let ( code, stdout, stderr) = common:: run_with_env (
712+ & proj,
713+ & [
714+ "scan" ,
715+ "--mode" ,
716+ "hosted" ,
717+ "--json" ,
718+ "--yes" ,
719+ "--cwd" ,
720+ proj. to_str ( ) . unwrap ( ) ,
721+ "--api-url" ,
722+ & server. uri ( ) ,
723+ "--org" ,
724+ ORG ,
725+ "--api-token" ,
726+ "fake" ,
727+ "--vex" ,
728+ vex_path. to_str ( ) . unwrap ( ) ,
729+ "--vex-product" ,
730+ "pkg:gem/app@1.0.0" ,
731+ ] ,
732+ & env,
733+ ) ;
734+ let envelope = common:: parse_json_envelope ( & stdout) ;
735+ let warnings = stale_warnings ( & envelope) ;
736+ assert_eq ! (
737+ warnings. len( ) ,
738+ 1 ,
739+ "{label}: the globally configured cache archive must warn: {envelope}\n stderr:\n {stderr}"
740+ ) ;
741+ assert ! (
742+ warnings[ 0 ] . contains( & archive. display( ) . to_string( ) ) ,
743+ "{label}: the warning must name the archive bundler installs from: {}" ,
744+ warnings[ 0 ]
745+ ) ;
746+ if let Ok ( doc) = std:: fs:: read_to_string ( & vex_path) {
747+ assert ! (
748+ !doc. contains( PURL ) ,
749+ "{label}: a stale purl must never be attested by the same run's VEX:\n {doc}"
750+ ) ;
751+ }
752+ assert_ne ! (
753+ code, 0 ,
754+ "{label}: an all-stale --vex run must fail, not attest.\n stdout:\n {stdout}"
755+ ) ;
756+ }
757+ }
758+
759+ /// #577: a global `gemfile` setting (`bundle config set --global gemfile
760+ /// Gemfile.next`) makes bundler load `Gemfile.next`, exactly like the local
761+ /// one #507 covers. The hosted scan must refuse with
762+ /// `redirect_gem_bundle_gemfile_unsupported`, leave the `Gemfile` pair
763+ /// untouched, and attest nothing.
764+ #[ tokio:: test( flavor = "multi_thread" ) ]
765+ async fn gem_hosted_global_gemfile_setting_is_refused ( ) {
766+ let server = MockServer :: start ( ) . await ;
767+ mount_api ( & server, None ) . await ;
768+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
769+ let proj = tmp. path ( ) . join ( "proj" ) ;
770+ std:: fs:: create_dir_all ( & proj) . unwrap ( ) ;
771+ write_manifest_pair ( & proj) ;
772+ std:: fs:: write (
773+ proj. join ( "Gemfile.next" ) ,
774+ format ! ( "source \" https://rubygems.org\" \n gem \" {DEP}\" , \" {DEP_VERSION}\" \n " ) ,
775+ )
776+ . unwrap ( ) ;
777+ let home = tmp. path ( ) . join ( "home" ) ;
778+ std:: fs:: create_dir_all ( home. join ( ".bundle" ) ) . unwrap ( ) ;
779+ std:: fs:: write (
780+ home. join ( ".bundle" ) . join ( "config" ) ,
781+ "---\n BUNDLE_GEMFILE: \" Gemfile.next\" \n " ,
782+ )
783+ . unwrap ( ) ;
784+ let pristine_gemfile = std:: fs:: read ( proj. join ( "Gemfile" ) ) . unwrap ( ) ;
785+ let pristine_lock = std:: fs:: read ( proj. join ( "Gemfile.lock" ) ) . unwrap ( ) ;
786+
787+ let vex_path = proj. join ( "out.vex.json" ) ;
788+ let ( code, stdout, stderr) = common:: run_with_env (
789+ & proj,
790+ & [
791+ "scan" ,
792+ "--mode" ,
793+ "hosted" ,
794+ "--json" ,
795+ "--yes" ,
796+ "--cwd" ,
797+ proj. to_str ( ) . unwrap ( ) ,
798+ "--api-url" ,
799+ & server. uri ( ) ,
800+ "--org" ,
801+ ORG ,
802+ "--api-token" ,
803+ "fake" ,
804+ "--vex" ,
805+ vex_path. to_str ( ) . unwrap ( ) ,
806+ "--vex-product" ,
807+ "pkg:gem/app@1.0.0" ,
808+ ] ,
809+ & [ ( "HOME" , home. to_str ( ) . unwrap ( ) ) ] ,
810+ ) ;
811+ let envelope = common:: parse_json_envelope ( & stdout) ;
812+ let warnings: Vec < & serde_json:: Value > = envelope[ "redirect" ] [ "warnings" ]
813+ . as_array ( )
814+ . map ( |a| a. iter ( ) . collect ( ) )
815+ . unwrap_or_default ( ) ;
816+ let refusal = warnings
817+ . iter ( )
818+ . find ( |w| w[ "code" ] == "redirect_gem_bundle_gemfile_unsupported" )
819+ . unwrap_or_else ( || {
820+ panic ! ( "the global gemfile setting must be refused: {envelope}\n stderr:\n {stderr}" )
821+ } ) ;
822+ let detail = refusal[ "detail" ] . as_str ( ) . unwrap_or_default ( ) ;
823+ assert ! (
824+ detail. contains( "global bundler config" ) && detail. contains( "--global" ) ,
825+ "the refusal must name the global setting and its remedy: {detail}"
826+ ) ;
827+ assert_eq ! (
828+ envelope[ "redirect" ] [ "redirected" ] , 0 ,
829+ "nothing redirected: {envelope}"
830+ ) ;
831+ assert_eq ! (
832+ std:: fs:: read( proj. join( "Gemfile" ) ) . unwrap( ) ,
833+ pristine_gemfile,
834+ "the Gemfile bundler ignores must be byte-identical"
835+ ) ;
836+ assert_eq ! (
837+ std:: fs:: read( proj. join( "Gemfile.lock" ) ) . unwrap( ) ,
838+ pristine_lock,
839+ "the lock must be byte-identical"
840+ ) ;
841+ if let Ok ( doc) = std:: fs:: read_to_string ( & vex_path) {
842+ assert ! (
843+ !doc. contains( PURL ) ,
844+ "nothing may be attested for a gem bundler installs unpatched:\n {doc}"
845+ ) ;
846+ }
847+ assert_ne ! ( code, 0 , "nothing was patched or attested: {envelope}" ) ;
848+ }
849+
637850/// 7. Manifest-less VEX (no `.socket/manifest.json` — hosted never writes
638851/// one) over the stale-install scenario, before and after the prescribed
639852/// fix. The two post-install lock shapes are the ones REAL bundler writes
0 commit comments