@@ -405,6 +405,17 @@ enum Driver {
405405 /// `Gemfile.next`, so the run must redirect nothing and attest nothing.
406406 /// The fixture asserts that contract itself and yields `None`.
407407 ScanVexDualBoot ,
408+ /// [`Driver::ScanVex`] on a Gemfile that declares the gem in two `group`
409+ /// blocks (#548): bundler accepts the duplicate, but rewriting only one
410+ /// declaration would leave conflicting requirements. The run must
411+ /// refuse, write nothing and attest nothing; the fixture asserts that
412+ /// and yields `None`.
413+ ScanVexDuplicateDeclaration ,
414+ /// [`Driver::ScanVex`] on a Gemfile that declares the gem through
415+ /// `eval_gemfile` (#482): the lock lists it as a direct dependency, so
416+ /// appending a source block would declare it twice. Same contract as
417+ /// [`Driver::ScanVexDuplicateDeclaration`].
418+ ScanVexEvalGemfile ,
408419}
409420
410421impl Driver {
@@ -413,6 +424,8 @@ impl Driver {
413424 Driver :: ScanVex => "scan --mode hosted" ,
414425 Driver :: GetUuid => "get <uuid> --mode hosted" ,
415426 Driver :: ScanVexDualBoot => "scan --mode hosted (BUNDLE_GEMFILE=Gemfile.next)" ,
427+ Driver :: ScanVexDuplicateDeclaration => "scan --mode hosted (gem in two groups)" ,
428+ Driver :: ScanVexEvalGemfile => "scan --mode hosted (gem via eval_gemfile)" ,
416429 }
417430 }
418431}
@@ -675,11 +688,22 @@ async fn redirect_scanned_project(
675688 // 3. The fixture project, installed from the MOCK upstream (hermetic).
676689 let proj = tmp. path ( ) . join ( "proj" ) ;
677690 std:: fs:: create_dir_all ( & proj) . unwrap ( ) ;
678- std:: fs:: write (
679- proj. join ( gemfile_name) ,
680- format ! ( "source \" {}/upstream\" \n \n gem \" {DEP}\" \n " , server. uri( ) ) ,
681- )
682- . unwrap ( ) ;
691+ let gemfile_body = match driver {
692+ Driver :: ScanVexDuplicateDeclaration => format ! (
693+ "source \" {}/upstream\" \n \n group :development do\n gem \" {DEP}\" \n end\n \n \
694+ group :test do\n gem \" {DEP}\" \n end\n ",
695+ server. uri( )
696+ ) ,
697+ Driver :: ScanVexEvalGemfile => {
698+ std:: fs:: write ( proj. join ( "Gemfile.common" ) , format ! ( "gem \" {DEP}\" \n " ) ) . unwrap ( ) ;
699+ format ! (
700+ "source \" {}/upstream\" \n \n eval_gemfile \" Gemfile.common\" \n " ,
701+ server. uri( )
702+ )
703+ }
704+ _ => format ! ( "source \" {}/upstream\" \n \n gem \" {DEP}\" \n " , server. uri( ) ) ,
705+ } ;
706+ std:: fs:: write ( proj. join ( gemfile_name) , gemfile_body) . unwrap ( ) ;
683707 let config_args = bundler. config_local_args ( "path" , "vendor/bundle" ) ;
684708 let config_args: Vec < & str > = config_args. iter ( ) . map ( String :: as_str) . collect ( ) ;
685709 let config = bundle ( & proj, & config_args) ;
@@ -775,7 +799,10 @@ async fn redirect_scanned_project(
775799 ) ;
776800 }
777801 let argv: Vec < & str > = match driver {
778- Driver :: ScanVex | Driver :: ScanVexDualBoot => vec ! [
802+ Driver :: ScanVex
803+ | Driver :: ScanVexDualBoot
804+ | Driver :: ScanVexDuplicateDeclaration
805+ | Driver :: ScanVexEvalGemfile => vec ! [
779806 "scan" ,
780807 "--mode" ,
781808 "hosted" ,
@@ -825,6 +852,21 @@ async fn redirect_scanned_project(
825852 assert_dual_boot_redirects_nothing ( & env, & proj, & pristine_gemfile, & pristine_lock) ;
826853 return None ;
827854 }
855+ if let Some ( warning) = match driver {
856+ Driver :: ScanVexDuplicateDeclaration => Some ( "redirect_gem_declared_more_than_once" ) ,
857+ Driver :: ScanVexEvalGemfile => Some ( "redirect_gem_declaration_not_visible" ) ,
858+ _ => None ,
859+ } {
860+ assert_unwirable_declaration_redirects_nothing (
861+ & proj,
862+ & bundler,
863+ warning,
864+ ( code, & stdout, & stderr) ,
865+ & pristine_gemfile,
866+ & pristine_lock,
867+ ) ;
868+ return None ;
869+ }
828870 assert_eq ! (
829871 code,
830872 0 ,
@@ -898,7 +940,9 @@ async fn redirect_scanned_project(
898940 "in-run hosted VEX is attested from this run's fetched record, not hash-verified: {env}"
899941 ) ;
900942 }
901- Driver :: ScanVexDualBoot => unreachable ! ( "asserted and returned above" ) ,
943+ Driver :: ScanVexDualBoot
944+ | Driver :: ScanVexDuplicateDeclaration
945+ | Driver :: ScanVexEvalGemfile => unreachable ! ( "asserted and returned above" ) ,
902946 Driver :: GetUuid => {
903947 // get's hosted envelope (CLI_CONTRACT.md "get --mode and
904948 // installed narrowing"): `found` counts the resolved patch;
@@ -952,6 +996,49 @@ async fn redirect_scanned_project(
952996 } )
953997}
954998
999+ /// #482 / #548: a Gemfile whose declarations of the gem the rewriter cannot
1000+ /// edit as one (two `group` blocks, an `eval_gemfile`d file). The scan names
1001+ /// the refusal, leaves the pair byte-identical and attests nothing, and the
1002+ /// real bundler still installs the project (before the fix the Gemfile was
1003+ /// left declaring the gem twice and every install exited 4).
1004+ fn assert_unwirable_declaration_redirects_nothing (
1005+ proj : & Path ,
1006+ bundler : & bundler_e2e:: Bundler ,
1007+ warning : & str ,
1008+ ( code, stdout, stderr) : ( i32 , & str , & str ) ,
1009+ pristine_gemfile : & [ u8 ] ,
1010+ pristine_lock : & [ u8 ] ,
1011+ ) {
1012+ let env: serde_json:: Value = serde_json:: from_str ( stdout)
1013+ . unwrap_or_else ( |e| panic ! ( "not JSON: {e}\n stdout:\n {stdout}\n stderr:\n {stderr}" ) ) ;
1014+ assert ! (
1015+ stdout. contains( warning) ,
1016+ "the refusal must be named ({warning}): {env}"
1017+ ) ;
1018+ assert_ne ! ( code, 0 , "nothing was patched or attested: {env}" ) ;
1019+ assert ! (
1020+ env[ "vex" ] [ "statements" ] . as_u64( ) . unwrap_or( 0 ) == 0 ,
1021+ "nothing may be attested: {env}"
1022+ ) ;
1023+ assert_eq ! (
1024+ std:: fs:: read( proj. join( "Gemfile" ) ) . unwrap( ) ,
1025+ pristine_gemfile,
1026+ "the Gemfile must be byte-identical"
1027+ ) ;
1028+ assert_eq ! (
1029+ std:: fs:: read( proj. join( "Gemfile.lock" ) ) . unwrap( ) ,
1030+ pristine_lock,
1031+ "the lock must be byte-identical"
1032+ ) ;
1033+ let install = bundle ( proj, & [ "install" ] ) ;
1034+ assert ! (
1035+ install. status. success( ) ,
1036+ "bundler {} must still install the untouched project:\n {}" ,
1037+ bundler. version,
1038+ String :: from_utf8_lossy( & install. stderr)
1039+ ) ;
1040+ }
1041+
9551042/// #390's contract on a `BUNDLE_GEMFILE: Gemfile.next` project: the hosted
9561043/// scan names the setting, rewrites neither the `Gemfile` pair (which
9571044/// bundler ignores) nor `Gemfile.next`, and its in-run VEX attests nothing.
@@ -1549,6 +1636,42 @@ async fn gem_hosted_bundle_gemfile_dual_boot_redirects_nothing() {
15491636 assert ! ( fx. is_none( ) , "the dual-boot driver asserts in place" ) ;
15501637}
15511638
1639+ /// #548: a gem declared in two `group` blocks must not be half-rewritten
1640+ /// (bundler refuses `= 1.0.0` next to `>= 0` on every install).
1641+ #[ tokio:: test( flavor = "multi_thread" ) ]
1642+ #[ ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17; CHECKSUMS arm >= 2.6); \
1643+ the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"]
1644+ async fn gem_hosted_gem_declared_in_two_groups_is_refused_and_still_installs ( ) {
1645+ let fx = redirect_scanned_project (
1646+ "two-groups" ,
1647+ Spelling :: Gemfile ,
1648+ false ,
1649+ true ,
1650+ None ,
1651+ Driver :: ScanVexDuplicateDeclaration ,
1652+ )
1653+ . await ;
1654+ assert ! ( fx. is_none( ) , "the duplicate-declaration driver asserts in place" ) ;
1655+ }
1656+
1657+ /// #482: a direct dependency declared through `eval_gemfile` must not get a
1658+ /// second, appended declaration.
1659+ #[ tokio:: test( flavor = "multi_thread" ) ]
1660+ #[ ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17; CHECKSUMS arm >= 2.6); \
1661+ the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"]
1662+ async fn gem_hosted_eval_gemfile_direct_dep_is_refused_and_still_installs ( ) {
1663+ let fx = redirect_scanned_project (
1664+ "eval-gemfile" ,
1665+ Spelling :: Gemfile ,
1666+ false ,
1667+ true ,
1668+ None ,
1669+ Driver :: ScanVexEvalGemfile ,
1670+ )
1671+ . await ;
1672+ assert ! ( fx. is_none( ) , "the eval_gemfile driver asserts in place" ) ;
1673+ }
1674+
15521675/// The compact-index DEPENDENCY contract, pinned from the red side: a patch
15531676/// registry whose `/info` omits the gem's runtime deps (production's
15541677/// HISTORICAL behavior until the 2026-08-18 republish fixed the served index)
0 commit comments