From 9a62f63713e155731fe85f255710e6ef7f1770c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 19:58:00 +0000 Subject: [PATCH] Attribute gems in nested Gemfile source blocks The nightly e2e-extended gem legs on bundler 1.17.3 and 2.1.4 fail in gem_hosted_redirect_keeps_a_crlf_gemfile_crlf (#1997). The hosted redirect wraps a gem the user declared in their own `source "..." do` block in a Socket block nested inside it. Bundler <= 2.1 writes one merged GEM section, so discovery attributes the gem only through the Gemfile source block that pins it. The Gemfile reader ignored nested blocks and gave the gem to the outer upstream block, so rollback refused with hosted_wiring_contested / patched_ref_unattributable. Track open `source ... do` blocks as a stack and scope each gem to the innermost one, as bundler's Dsl#with_source does. The same reader backs ledger liveness and manifest_source_pins, which now see the nested Socket pin too. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011f9aeSZhUZfmB5eQt427Fy --- .../socket-patch-core/src/vex/discover/gem.rs | 149 ++++++++++++------ 1 file changed, 101 insertions(+), 48 deletions(-) diff --git a/crates/socket-patch-core/src/vex/discover/gem.rs b/crates/socket-patch-core/src/vex/discover/gem.rs index 77bd8d140..97a5e02cc 100644 --- a/crates/socket-patch-core/src/vex/discover/gem.rs +++ b/crates/socket-patch-core/src/vex/discover/gem.rs @@ -212,12 +212,17 @@ async fn source_block_gems( /// The Gemfile grammar [`source_block_gems`] reads: a line /// `source "" do` / `source('') do` opens a block, a line starting -/// with `end` closes it, and each `gem ""` / `gem(''…` line in -/// between (the vendor backend's own [`gem_declaration_any`] grammar) -/// declares a block-scoped gem. Anything else (nested blocks, -/// `source` with a block argument, `#` comments, and Ruby's column-0 -/// `=begin` … `=end` block comments) is ignored — this only ever ADDS a -/// cross-check to lock evidence, never creates a ref on its own. +/// with `end` closes the innermost open one, and each `gem ""` / +/// `gem(''…` line inside a block (the vendor backend's own +/// [`gem_declaration_any`] grammar) declares a gem scoped to the INNERMOST +/// open block, as bundler's `Dsl#with_source` does. Nesting matters: the +/// hosted redirect wraps a gem the user declared in their own +/// `source "…" do` block in a Socket block INSIDE it, so on a merged +/// (bundler <= 2.1) lock the gem is attributable only through the inner +/// block (#1997). Anything else (`source` with a block argument, `#` +/// comments, and Ruby's column-0 `=begin` … `=end` block comments) is +/// ignored — this only ever ADDS a cross-check to lock evidence, never +/// creates a ref on its own. fn parse_source_blocks(gemfile: &str) -> Vec<(String, String)> { parse_source_block_decls(gemfile) .into_iter() @@ -236,7 +241,8 @@ struct BlockDecl { /// [`parse_source_blocks`] keeping each declaration's argument tail. fn parse_source_block_decls(gemfile: &str) -> Vec { let mut out = Vec::new(); - let mut open: Option = None; + // The open `source … do` blocks, innermost last. + let mut open: Vec = Vec::new(); let mut block_comment = false; for raw in gemfile.lines() { // `=begin` / `=end` must start at column 0 and be followed by @@ -257,51 +263,47 @@ fn parse_source_block_decls(gemfile: &str) -> Vec { if line.starts_with('#') { continue; } - match &open { - None => { - let Some(rest) = line.strip_prefix("source") else { - continue; - }; - let rest = rest.trim_start(); - let (rest, paren) = match rest.strip_prefix('(') { - Some(r) => (r.trim_start(), true), - None => (rest, false), - }; - let Some((_, url, tail)) = quoted_literal(rest) else { - continue; - }; - let tail = tail.trim_start(); - let tail = if paren { - match tail.strip_prefix(')') { - Some(t) => t.trim_start(), - None => continue, - } - } else { - tail - }; - let tail = tail.split('#').next().unwrap_or_default().trim_end(); - if tail == "do" { - open = Some(url.to_string()); - } - } - Some(url) => { - if line == "end" || line.starts_with("end ") || line.starts_with("end#") { - open = None; - continue; - } - if let Some(decl) = gem_declaration_any(line) { - out.push(BlockDecl { - url: url.clone(), - name: decl.name.to_string(), - tail: decl.rest.to_string(), - }); - } - } + if let Some(url) = source_block_opener(line) { + open.push(url.to_string()); + continue; + } + let Some(url) = open.last() else { + continue; + }; + if line == "end" || line.starts_with("end ") || line.starts_with("end#") { + open.pop(); + continue; + } + if let Some(decl) = gem_declaration_any(line) { + out.push(BlockDecl { + url: url.clone(), + name: decl.name.to_string(), + tail: decl.rest.to_string(), + }); } } out } +/// The URL of a trimmed Gemfile line that opens a `source "" do` / +/// `source('') do` block (a trailing `#` comment allowed), else `None`. +fn source_block_opener(line: &str) -> Option<&str> { + let rest = line.strip_prefix("source")?.trim_start(); + let (rest, paren) = match rest.strip_prefix('(') { + Some(r) => (r.trim_start(), true), + None => (rest, false), + }; + let (_, url, tail) = quoted_literal(rest)?; + let tail = tail.trim_start(); + let tail = if paren { + tail.strip_prefix(')')?.trim_start() + } else { + tail + }; + let tail = tail.split('#').next().unwrap_or_default().trim_end(); + (tail == "do").then_some(url) +} + /// Whether the bundler manifest text `gemfile` still routes a gem to patch /// `uuid`'s registry: a `source "<…/patch-registry/gem//>" do` /// block ([`is_patch_registry_index`], host-agnostic) declaring `gem_name` @@ -1267,6 +1269,45 @@ mod tests { assert_eq!(run(&p).await.refs.len(), 1); } + /// #1997: a gem the user declared in their own `source "…" do` block is + /// redirected by a Socket block NESTED inside it (CRLF or not); bundler + /// resolves it from the innermost source only, so on a merged lock the + /// inner block attributes it. Nested the other way round (the Socket + /// block outside, another source innermost), it is not attributed. + #[tokio::test] + async fn merged_section_nested_source_block_is_attributed_innermost() { + let socket = index(UUID_A); + let nested = format!( + "source \"https://rubygems.org\"\n\nsource \"https://rubygems.org\" do\n\ + source \"{socket}\" do\n gem \"vuln-gem\", \"1.0.0\"\nend\nend\n" + ); + for gemfile in [nested.clone(), nested.replace('\n', "\r\n")] { + let p = Project::new(); + p.write("Gemfile.lock", merged_lock(&socket, "!")); + p.write("Gemfile", &gemfile); + let out = run(&p).await; + assert_refs( + &out, + &[("pkg:gem/vuln-gem@1.0.0", UUID_A, WiringMode::Hosted)], + ); + assert!( + out.diagnostics.is_empty(), + "{gemfile:?}: {:?}", + out.diagnostics + ); + } + let inverted = format!( + "source \"{socket}\" do\n source \"https://rubygems.org\" do\n \ + gem \"vuln-gem\", \"1.0.0\"\n end\nend\n" + ); + let p = Project::new(); + p.write("Gemfile.lock", merged_lock(&socket, "!")); + p.write("Gemfile", &inverted); + let out = run(&p).await; + assert!(out.refs.is_empty(), "{:#?}", out.refs); + assert_eq!(diag_codes(&out), vec![DIAG_REF_UNATTRIBUTABLE]); + } + /// Each half of the source pin is required, and the block must name /// THIS remote. #[tokio::test] @@ -1586,7 +1627,16 @@ mod tests { source \"https://d.example/\" do\n gem 'ghost'\nend\n\ =end\n\ =beginning is not a marker\n\ - gem 'after'\n", + gem 'after'\n\ + source 'https://outer.example/' do\n\ + \x20 gem 'outer-1'\n\ + \x20 source 'https://inner.example/' do # nested\n\ + \x20 gem 'inner'\n\ + \x20 end\n\ + \x20 gem 'outer-2'\n\ + end\n\ + end\n\ + gem 'top'\n", ); let got: Vec<(&str, &str)> = got.iter().map(|(u, g)| (u.as_str(), g.as_str())).collect(); assert_eq!( @@ -1595,6 +1645,9 @@ mod tests { ("https://a.example/", "one"), ("https://a.example/", "two"), ("https://b.example/", "three"), + ("https://outer.example/", "outer-1"), + ("https://inner.example/", "inner"), + ("https://outer.example/", "outer-2"), ] ); }