@@ -780,43 +780,45 @@ async fn gem_service_copy( |
780 | 780 | // generator omitted the rubygems-required `summary`/`authors`, and every |
781 | 781 | // bundler major validates path-source gemspecs — writing such a stub |
782 | 782 | // 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`. |
788 | 787 | let stub_text = String::from_utf8_lossy(&stub); |
789 | 788 | let missing_attrs = gemspec_missing_required_attrs(&stub_text); |
790 | | - if !missing_attrs.is_empty() { |
| 789 | + let stub = if !missing_attrs.is_empty() { |
791 | 790 | let licenses_note = if gemspec_assigns_attr(&stub_text, "licenses") |
792 | 791 | || gemspec_assigns_attr(&stub_text, "license") |
793 | 792 | { |
794 | 793 | "" |
795 | 794 | } else { |
796 | 795 | " (it also omits `licenses`, a rubygems warning)" |
797 | 796 | }; |
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 | | - } |
814 | 797 | warnings.push(VendorWarning::new( |
815 | 798 | "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 | + ), |
817 | 805 | )); |
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 | + }; |
820 | 822 |
|
821 | 823 | // Extract the patched `.gem`'s data.tar.gz into a clean copy dir, then add |
822 | 824 | // the stub as `<name>.gemspec` (a `.gem`'s data.tar.gz never carries one — |
@@ -4490,11 +4492,10 @@ mod tests { |
4490 | 4492 | } |
4491 | 4493 |
|
4492 | 4494 | /// 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. |
4498 | 4499 | #[tokio::test] |
4499 | 4500 | async fn service_stub_invalid_service_mode_hard_fails() { |
4500 | 4501 | let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; |
@@ -4521,27 +4522,37 @@ mod tests { |
4521 | 4522 | )), |
4522 | 4523 | ) |
4523 | 4524 | .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(); |
4526 | 4532 | 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}" |
4529 | 4535 | ); |
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 |
4537 | 4548 | ); |
4538 | 4549 | } |
4539 | 4550 |
|
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. |
4545 | 4556 | #[tokio::test] |
4546 | 4557 | async fn service_stub_invalid_auto_falls_back_to_build() { |
4547 | 4558 | let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; |
@@ -4565,20 +4576,24 @@ mod tests { |
4565 | 4576 | ) |
4566 | 4577 | .await; |
4567 | 4578 | 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); |
4569 | 4580 | assert!(entry.is_some()); |
4570 | 4581 | 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}" |
4577 | 4592 | ); |
4578 | 4593 | let warning = warnings |
4579 | 4594 | .iter() |
4580 | 4595 | .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"); |
4582 | 4597 | assert!( |
4583 | 4598 | warning.detail.contains("summary") && warning.detail.contains("authors"), |
4584 | 4599 | "the warning must name the missing attributes: {}", |
|