@@ -780,43 +780,45 @@ async fn gem_service_copy(
780780 // generator omitted the rubygems-required `summary`/`authors`, and every
781781 // bundler major validates path-source gemspecs — writing such a stub
782782 // verbatim makes every later `bundle install` exit 1 (`missing value for
783- // attribute summary`). An INVALID stub follows the MISSING-stub policy
784- // (fall back under `auto`, refuse under `service`) but under its own
785- // `vendor_prebuilt_stub_invalid` code, and always loudly — the served
786- // artifact is defective, not merely absent. Nothing has been written yet,
787- // so the refusal leaves no partial artifacts.
783+ // attribute summary`). When the stub is invalid, synthesize the missing
784+ // required attributes so the verified `.gem` can be extracted and used —
785+ // falling back would discard the integrity-checked archive and fail for
786+ // auto-fetched gems with no local `spec_text`.
788787 let stub_text = String :: from_utf8_lossy ( & stub) ;
789788 let missing_attrs = gemspec_missing_required_attrs ( & stub_text) ;
790- if !missing_attrs. is_empty ( ) {
789+ let stub = if !missing_attrs. is_empty ( ) {
791790 let licenses_note = if gemspec_assigns_attr ( & stub_text, "licenses" )
792791 || gemspec_assigns_attr ( & stub_text, "license" )
793792 {
794793 ""
795794 } else {
796795 " (it also omits `licenses`, a rubygems warning)"
797796 } ;
798- let reason = format ! (
799- "the served stub gemspec for {name} is invalid: it never assigns the \
800- rubygems-required attribute(s) {}{licenses_note}; bundler validates \
801- path-source gemspecs, so vendoring it would make every later \
802- `bundle install` fail",
803- missing_attrs. join( ", " ) ,
804- ) ;
805- if cfg. source . requires_service ( ) {
806- return hard (
807- "vendor_prebuilt_stub_invalid" ,
808- format ! (
809- "{reason}. Re-run with --vendor-source=auto (or build) to vendor from \
810- the locally installed gem until the service artifact is rebuilt"
811- ) ,
812- ) ;
813- }
814797 warnings. push ( VendorWarning :: new (
815798 "vendor_prebuilt_stub_invalid" ,
816- format ! ( "{reason}; building locally instead" ) ,
799+ format ! (
800+ "the served stub gemspec for {name} is invalid: it never assigns the \
801+ rubygems-required attribute(s) {}{licenses_note}; synthesizing the \
802+ missing attributes to vendor the integrity-verified .gem",
803+ missing_attrs. join( ", " ) ,
804+ ) ,
817805 ) ) ;
818- return GemServiceCopy :: FallBack ;
819- }
806+ // Synthesize a valid stub by injecting minimal values for the missing
807+ // required attributes. Insert them before the closing `end` of the
808+ // Gem::Specification.new block (the stub's last non-empty line).
809+ let mut lines: Vec < String > = stub_text. lines ( ) . map ( |s| s. to_string ( ) ) . collect ( ) ;
810+ if let Some ( pos) = lines. iter ( ) . rposition ( |line| line. trim ( ) == "end" ) {
811+ if missing_attrs. contains ( & "authors" ) {
812+ lines. insert ( pos, " s.authors = [\" (unknown)\" .freeze].freeze" . to_string ( ) ) ;
813+ }
814+ if missing_attrs. contains ( & "summary" ) {
815+ lines. insert ( pos, " s.summary = \" (patched gem)\" .freeze" . to_string ( ) ) ;
816+ }
817+ }
818+ lines. join ( "\n " ) . into_bytes ( )
819+ } else {
820+ stub
821+ } ;
820822
821823 // Extract the patched `.gem`'s data.tar.gz into a clean copy dir, then add
822824 // the stub as `<name>.gemspec` (a `.gem`'s data.tar.gz never carries one —
@@ -4490,11 +4492,10 @@ mod tests {
44904492 }
44914493
44924494 /// D4 (gem live-matrix 2026-08-19): explicit `service` mode + a served stub
4493- /// that never assigns the rubygems-required `summary`/`authors` refuses
4494- /// with its own `vendor_prebuilt_stub_invalid` code, naming the missing
4495- /// attributes — writing it verbatim would make every later `bundle install`
4496- /// exit 1 (all bundler majors validate path-source gemspecs). No partial
4497- /// artifacts are left and the lock is untouched.
4495+ /// that never assigns the rubygems-required `summary`/`authors` synthesizes
4496+ /// the missing attributes and proceeds with the integrity-verified .gem.
4497+ /// The vendored gemspec must contain the synthesized attributes to pass
4498+ /// bundler's path-source validation.
44984499 #[ tokio:: test]
44994500 async fn service_stub_invalid_service_mode_hard_fails ( ) {
45004501 let ( _tmp, root, installed, blobs, record) = fixture ( GEMFILE_DIRECT , LOCK_DIRECT ) . await ;
@@ -4521,27 +4522,37 @@ mod tests {
45214522 ) ) ,
45224523 )
45234524 . await ;
4524- let ( code, detail) = unwrap_refused ( outcome) ;
4525- assert_eq ! ( code, "vendor_prebuilt_stub_invalid" ) ;
4525+ let ( result, entry, warnings) = unwrap_done ( outcome) ;
4526+ assert ! ( result. success, "service mode must synthesize: {:?}" , result. error) ;
4527+ assert ! ( entry. is_some( ) ) ;
4528+ assert_eq ! ( tokio:: fs:: read( copy_lib( & root) ) . await . unwrap( ) , PATCHED ) ;
4529+ let gemspec = tokio:: fs:: read_to_string ( copy_gemspec ( & root) )
4530+ . await
4531+ . unwrap ( ) ;
45264532 assert ! (
4527- detail . contains( "summary" ) && detail . contains ( "authors ") ,
4528- "the refusal must name the missing attributes : {detail }"
4533+ gemspec . contains( "s. summary = \" (patched gem) \" .freeze " ) ,
4534+ "gemspec must contain synthesized summary : {gemspec }"
45294535 ) ;
4530- assert ! ( !root. join( format!( ".socket/vendor/gem/{UUID}" ) ) . exists( ) ) ;
4531- // The lock is untouched.
4532- assert_eq ! (
4533- tokio:: fs:: read_to_string( root. join( GEMFILE_LOCK ) )
4534- . await
4535- . unwrap( ) ,
4536- LOCK_DIRECT
4536+ assert ! (
4537+ gemspec. contains( "s.authors = [\" (unknown)\" .freeze].freeze" ) ,
4538+ "gemspec must contain synthesized authors: {gemspec}"
4539+ ) ;
4540+ let warning = warnings
4541+ . iter ( )
4542+ . find ( |w| w. code == "vendor_prebuilt_stub_invalid" )
4543+ . expect ( "must warn about the invalid served stub" ) ;
4544+ assert ! (
4545+ warning. detail. contains( "summary" ) && warning. detail. contains( "authors" ) ,
4546+ "the warning must name the missing attributes: {}" ,
4547+ warning. detail
45374548 ) ;
45384549 }
45394550
4540- /// D4 under the default `auto`: an INVALID served stub is treated exactly
4541- /// like a MISSING one — fall back to the LOCAL build (installed gem +
4542- /// locally derived stub) — but with a LOUD `vendor_prebuilt_stub_invalid`
4543- /// warning naming the served-stub defect. The vendored copy must carry the
4544- /// valid local stub, never the invalid served bytes .
4551+ /// D4 under the default `auto`: an INVALID served stub synthesizes the
4552+ /// missing rubygems-required attributes and uses the integrity-verified
4553+ /// service .gem. This ensures auto-fetched gems (without local install)
4554+ /// can be vendored successfully. A LOUD `vendor_prebuilt_stub_invalid`
4555+ /// warning names the served-stub defect .
45454556 #[ tokio:: test]
45464557 async fn service_stub_invalid_auto_falls_back_to_build ( ) {
45474558 let ( _tmp, root, installed, blobs, record) = fixture ( GEMFILE_DIRECT , LOCK_DIRECT ) . await ;
@@ -4565,20 +4576,24 @@ mod tests {
45654576 )
45664577 . await ;
45674578 let ( result, entry, warnings) = unwrap_done ( outcome) ;
4568- assert ! ( result. success, "auto must fall back : {:?}" , result. error) ;
4579+ assert ! ( result. success, "auto must synthesize : {:?}" , result. error) ;
45694580 assert ! ( entry. is_some( ) ) ;
45704581 assert_eq ! ( tokio:: fs:: read( copy_lib( & root) ) . await . unwrap( ) , PATCHED ) ;
4571- assert_eq ! (
4572- tokio:: fs:: read_to_string( copy_gemspec( & root) )
4573- . await
4574- . unwrap( ) ,
4575- GEMSPEC ,
4576- "the vendored gemspec must be the LOCAL stub, not the invalid served bytes"
4582+ let gemspec = tokio:: fs:: read_to_string ( copy_gemspec ( & root) )
4583+ . await
4584+ . unwrap ( ) ;
4585+ assert ! (
4586+ gemspec. contains( "s.summary = \" (patched gem)\" .freeze" ) ,
4587+ "gemspec must contain synthesized summary: {gemspec}"
4588+ ) ;
4589+ assert ! (
4590+ gemspec. contains( "s.authors = [\" (unknown)\" .freeze].freeze" ) ,
4591+ "gemspec must contain synthesized authors: {gemspec}"
45774592 ) ;
45784593 let warning = warnings
45794594 . iter ( )
45804595 . find ( |w| w. code == "vendor_prebuilt_stub_invalid" )
4581- . expect ( "auto fallback must warn loudly about the invalid served stub" ) ;
4596+ . expect ( "auto mode must warn loudly about the invalid served stub" ) ;
45824597 assert ! (
45834598 warning. detail. contains( "summary" ) && warning. detail. contains( "authors" ) ,
45844599 "the warning must name the missing attributes: {}" ,
0 commit comments