@@ -212,12 +212,17 @@ async fn source_block_gems( |
212 | 212 |
|
213 | 213 | /// The Gemfile grammar [`source_block_gems`] reads: a line |
214 | 214 | /// `source "<url>" do` / `source('<url>') do` opens a block, a line starting |
215 | | -/// with `end` closes it, and each `gem "<name>"` / `gem('<name>'…` line in |
216 | | -/// between (the vendor backend's own [`gem_declaration_any`] grammar) |
217 | | -/// declares a block-scoped gem. Anything else (nested blocks, |
218 | | -/// `source` with a block argument, `#` comments, and Ruby's column-0 |
219 | | -/// `=begin` … `=end` block comments) is ignored — this only ever ADDS a |
220 | | -/// cross-check to lock evidence, never creates a ref on its own. |
| 215 | +/// with `end` closes the innermost open one, and each `gem "<name>"` / |
| 216 | +/// `gem('<name>'…` line inside a block (the vendor backend's own |
| 217 | +/// [`gem_declaration_any`] grammar) declares a gem scoped to the INNERMOST |
| 218 | +/// open block, as bundler's `Dsl#with_source` does. Nesting matters: the |
| 219 | +/// hosted redirect wraps a gem the user declared in their own |
| 220 | +/// `source "…" do` block in a Socket block INSIDE it, so on a merged |
| 221 | +/// (bundler <= 2.1) lock the gem is attributable only through the inner |
| 222 | +/// block (#1997). Anything else (`source` with a block argument, `#` |
| 223 | +/// comments, and Ruby's column-0 `=begin` … `=end` block comments) is |
| 224 | +/// ignored — this only ever ADDS a cross-check to lock evidence, never |
| 225 | +/// creates a ref on its own. |
221 | 226 | fn parse_source_blocks(gemfile: &str) -> Vec<(String, String)> { |
222 | 227 | parse_source_block_decls(gemfile) |
223 | 228 | .into_iter() |
@@ -236,7 +241,8 @@ struct BlockDecl { |
236 | 241 | /// [`parse_source_blocks`] keeping each declaration's argument tail. |
237 | 242 | fn parse_source_block_decls(gemfile: &str) -> Vec<BlockDecl> { |
238 | 243 | let mut out = Vec::new(); |
239 | | - let mut open: Option<String> = None; |
| 244 | + // The open `source … do` blocks, innermost last. |
| 245 | + let mut open: Vec<String> = Vec::new(); |
240 | 246 | let mut block_comment = false; |
241 | 247 | for raw in gemfile.lines() { |
242 | 248 | // `=begin` / `=end` must start at column 0 and be followed by |
@@ -257,51 +263,47 @@ fn parse_source_block_decls(gemfile: &str) -> Vec<BlockDecl> { |
257 | 263 | if line.starts_with('#') { |
258 | 264 | continue; |
259 | 265 | } |
260 | | - match &open { |
261 | | - None => { |
262 | | - let Some(rest) = line.strip_prefix("source") else { |
263 | | - continue; |
264 | | - }; |
265 | | - let rest = rest.trim_start(); |
266 | | - let (rest, paren) = match rest.strip_prefix('(') { |
267 | | - Some(r) => (r.trim_start(), true), |
268 | | - None => (rest, false), |
269 | | - }; |
270 | | - let Some((_, url, tail)) = quoted_literal(rest) else { |
271 | | - continue; |
272 | | - }; |
273 | | - let tail = tail.trim_start(); |
274 | | - let tail = if paren { |
275 | | - match tail.strip_prefix(')') { |
276 | | - Some(t) => t.trim_start(), |
277 | | - None => continue, |
278 | | - } |
279 | | - } else { |
280 | | - tail |
281 | | - }; |
282 | | - let tail = tail.split('#').next().unwrap_or_default().trim_end(); |
283 | | - if tail == "do" { |
284 | | - open = Some(url.to_string()); |
285 | | - } |
286 | | - } |
287 | | - Some(url) => { |
288 | | - if line == "end" || line.starts_with("end ") || line.starts_with("end#") { |
289 | | - open = None; |
290 | | - continue; |
291 | | - } |
292 | | - if let Some(decl) = gem_declaration_any(line) { |
293 | | - out.push(BlockDecl { |
294 | | - url: url.clone(), |
295 | | - name: decl.name.to_string(), |
296 | | - tail: decl.rest.to_string(), |
297 | | - }); |
298 | | - } |
299 | | - } |
| 266 | + if let Some(url) = source_block_opener(line) { |
| 267 | + open.push(url.to_string()); |
| 268 | + continue; |
| 269 | + } |
| 270 | + let Some(url) = open.last() else { |
| 271 | + continue; |
| 272 | + }; |
| 273 | + if line == "end" || line.starts_with("end ") || line.starts_with("end#") { |
| 274 | + open.pop(); |
| 275 | + continue; |
| 276 | + } |
| 277 | + if let Some(decl) = gem_declaration_any(line) { |
| 278 | + out.push(BlockDecl { |
| 279 | + url: url.clone(), |
| 280 | + name: decl.name.to_string(), |
| 281 | + tail: decl.rest.to_string(), |
| 282 | + }); |
300 | 283 | } |
301 | 284 | } |
302 | 285 | out |
303 | 286 | } |
304 | 287 |
|
| 288 | +/// The URL of a trimmed Gemfile line that opens a `source "<url>" do` / |
| 289 | +/// `source('<url>') do` block (a trailing `#` comment allowed), else `None`. |
| 290 | +fn source_block_opener(line: &str) -> Option<&str> { |
| 291 | + let rest = line.strip_prefix("source")?.trim_start(); |
| 292 | + let (rest, paren) = match rest.strip_prefix('(') { |
| 293 | + Some(r) => (r.trim_start(), true), |
| 294 | + None => (rest, false), |
| 295 | + }; |
| 296 | + let (_, url, tail) = quoted_literal(rest)?; |
| 297 | + let tail = tail.trim_start(); |
| 298 | + let tail = if paren { |
| 299 | + tail.strip_prefix(')')?.trim_start() |
| 300 | + } else { |
| 301 | + tail |
| 302 | + }; |
| 303 | + let tail = tail.split('#').next().unwrap_or_default().trim_end(); |
| 304 | + (tail == "do").then_some(url) |
| 305 | +} |
| 306 | + |
305 | 307 | /// Whether the bundler manifest text `gemfile` still routes a gem to patch |
306 | 308 | /// `uuid`'s registry: a `source "<…/patch-registry/gem/<token>/<uuid>>" do` |
307 | 309 | /// block ([`is_patch_registry_index`], host-agnostic) declaring `gem_name` |
@@ -1267,6 +1269,45 @@ mod tests { |
1267 | 1269 | assert_eq!(run(&p).await.refs.len(), 1); |
1268 | 1270 | } |
1269 | 1271 |
|
| 1272 | + /// #1997: a gem the user declared in their own `source "…" do` block is |
| 1273 | + /// redirected by a Socket block NESTED inside it (CRLF or not); bundler |
| 1274 | + /// resolves it from the innermost source only, so on a merged lock the |
| 1275 | + /// inner block attributes it. Nested the other way round (the Socket |
| 1276 | + /// block outside, another source innermost), it is not attributed. |
| 1277 | + #[tokio::test] |
| 1278 | + async fn merged_section_nested_source_block_is_attributed_innermost() { |
| 1279 | + let socket = index(UUID_A); |
| 1280 | + let nested = format!( |
| 1281 | + "source \"https://rubygems.org\"\n\nsource \"https://rubygems.org\" do\n\ |
| 1282 | + source \"{socket}\" do\n gem \"vuln-gem\", \"1.0.0\"\nend\nend\n" |
| 1283 | + ); |
| 1284 | + for gemfile in [nested.clone(), nested.replace('\n', "\r\n")] { |
| 1285 | + let p = Project::new(); |
| 1286 | + p.write("Gemfile.lock", merged_lock(&socket, "!")); |
| 1287 | + p.write("Gemfile", &gemfile); |
| 1288 | + let out = run(&p).await; |
| 1289 | + assert_refs( |
| 1290 | + &out, |
| 1291 | + |
| 1292 | + ); |
| 1293 | + assert!( |
| 1294 | + out.diagnostics.is_empty(), |
| 1295 | + "{gemfile:?}: {:?}", |
| 1296 | + out.diagnostics |
| 1297 | + ); |
| 1298 | + } |
| 1299 | + let inverted = format!( |
| 1300 | + "source \"{socket}\" do\n source \"https://rubygems.org\" do\n \ |
| 1301 | + gem \"vuln-gem\", \"1.0.0\"\n end\nend\n" |
| 1302 | + ); |
| 1303 | + let p = Project::new(); |
| 1304 | + p.write("Gemfile.lock", merged_lock(&socket, "!")); |
| 1305 | + p.write("Gemfile", &inverted); |
| 1306 | + let out = run(&p).await; |
| 1307 | + assert!(out.refs.is_empty(), "{:#?}", out.refs); |
| 1308 | + assert_eq!(diag_codes(&out), vec![DIAG_REF_UNATTRIBUTABLE]); |
| 1309 | + } |
| 1310 | + |
1270 | 1311 | /// Each half of the source pin is required, and the block must name |
1271 | 1312 | /// THIS remote. |
1272 | 1313 | #[tokio::test] |
@@ -1586,7 +1627,16 @@ mod tests { |
1586 | 1627 | source \"https://d.example/\" do\n gem 'ghost'\nend\n\ |
1587 | 1628 | =end\n\ |
1588 | 1629 | =beginning is not a marker\n\ |
1589 | | - gem 'after'\n", |
| 1630 | + gem 'after'\n\ |
| 1631 | + source 'https://outer.example/' do\n\ |
| 1632 | + \x20 gem 'outer-1'\n\ |
| 1633 | + \x20 source 'https://inner.example/' do # nested\n\ |
| 1634 | + \x20 gem 'inner'\n\ |
| 1635 | + \x20 end\n\ |
| 1636 | + \x20 gem 'outer-2'\n\ |
| 1637 | + end\n\ |
| 1638 | + end\n\ |
| 1639 | + gem 'top'\n", |
1590 | 1640 | ); |
1591 | 1641 | let got: Vec<(&str, &str)> = got.iter().map(|(u, g)| (u.as_str(), g.as_str())).collect(); |
1592 | 1642 | assert_eq!( |
@@ -1595,6 +1645,9 @@ mod tests { |
1595 | 1645 | ("https://a.example/", "one"), |
1596 | 1646 | ("https://a.example/", "two"), |
1597 | 1647 | ("https://b.example/", "three"), |
| 1648 | + ("https://outer.example/", "outer-1"), |
| 1649 | + ("https://inner.example/", "inner"), |
| 1650 | + ("https://outer.example/", "outer-2"), |
1598 | 1651 | ] |
1599 | 1652 | ); |
1600 | 1653 | } |
|