Skip to content

Commit 03e457e

Browse files
committed
Fix invalid stub fallback to extract and use service gem
When an invalid served stub is detected (missing required attributes like summary/authors), synthesize the missing attributes and proceed with the integrity-verified .gem instead of falling back and discarding it. This fixes the bug where auto-fetched gems (no local install) would fail with gem_spec_missing after the fallback, and service mode would hard-fail with vendor_prebuilt_stub_invalid. The fix injects minimal valid values for missing attributes into the stub before extraction, ensuring the vendored gemspec passes bundler's path-source validation while preserving the verified .gem content.
1 parent d8ba029 commit 03e457e

1 file changed

Lines changed: 69 additions & 54 deletions

File tree

  • crates/socket-patch-core/src/vendor

crates/socket-patch-core/src/vendor/gem.rs

Lines changed: 69 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)