Skip to content

Commit d201fce

Browse files
Fix gem rewrite deleting a ;-joined declaration (#826) (#875)
* Start fix for #826 Assisted-by: Claude Code:claude-opus-5-5 * Keep gem declarations sharing a line with ; A Gemfile line like `gem "a", "1"; gem "b", "2"` had its second declaration deleted when socket-patch redirected or vendored gem "a", because the rewrite replaces the whole line and the safety check did not know that `;` starts a new statement. The next frozen `bundle install` then failed. Such lines are now refused with a warning and left untouched. A declaration ending in a bare `;` (optionally followed by a comment) was refused as "continues on the next line" since #637. It is complete, so it is rewritten again, without the `;`. Fixes #826 Assisted-by: Claude Code:claude-opus-5-5 * Use the reported line shape in the ; e2e test The `;`-joined fixture had no version argument, so the old check already refused it as "unexpected tokens" and the test passed without the fix. Use `gem "x", "v"; gem "y", "v"` from #826, which the old code rewrote and lost the second gem. Assisted-by: Claude Code:claude-opus-5-5 * Port #878: route Gradle digests through helpers main is red: #646 added inline sha1/sha256 calls that #865's production_digests_go_through_the_helpers guard rejects. This ports the fix from #878 so this PR's coverage job can go green. It becomes a no-op once #878 lands. Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8999750 commit d201fce

4 files changed

Lines changed: 302 additions & 5 deletions

File tree

‎crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs‎

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,13 @@ enum Driver {
445445
ScanVexHeredocDeclaration,
446446
/// A double-quoted interpolation can itself contain a heredoc opener.
447447
ScanVexInterpolatedHeredocDeclaration,
448+
/// A second declaration joined to the gem's line by `;` (#826): the
449+
/// line rewrite would delete it. Same contract as
450+
/// [`Driver::ScanVexDuplicateDeclaration`].
451+
ScanVexSemicolonJoinedDeclaration,
452+
/// [`Driver::ScanVex`] on a declaration ending in a bare `;` and a
453+
/// comment (#826): a complete declaration, so it is redirected.
454+
ScanVexTrailingSemicolonDeclaration,
448455
/// [`Driver::ScanVexDualBoot`] with `BUNDLE_GEMFILE=Gemfile` exported to
449456
/// socket-patch too (#507): bundler's local app config outranks the
450457
/// environment, so bundler still loads `Gemfile.next` and the run must
@@ -495,6 +502,12 @@ impl Driver {
495502
Driver::ScanVexDualBootEnvGemfile => {
496503
"scan --mode hosted (config Gemfile.next, env BUNDLE_GEMFILE=Gemfile)"
497504
}
505+
Driver::ScanVexSemicolonJoinedDeclaration => {
506+
"scan --mode hosted (two `;`-joined gem declarations)"
507+
}
508+
Driver::ScanVexTrailingSemicolonDeclaration => {
509+
"scan --mode hosted (gem line ending in `;`)"
510+
}
498511
Driver::ScanVexCustomGitSource => "scan --mode hosted (gem from a custom git_source)",
499512
}
500513
}
@@ -833,6 +846,14 @@ async fn redirect_scanned_project(
833846
"source \"{}/upstream\"\n\ngem \"{DEP}\", require: \"#{{<<~REQUIRE_PATH}}\".chomp\n vuln_gem\nREQUIRE_PATH\n",
834847
server.uri()
835848
),
849+
Driver::ScanVexSemicolonJoinedDeclaration => format!(
850+
"source \"{}/upstream\"\n\ngem \"{DEP}\", \"{DEP_VERSION}\"; gem \"{TRANSITIVE}\", \"1.0.0\"\n",
851+
server.uri()
852+
),
853+
Driver::ScanVexTrailingSemicolonDeclaration => format!(
854+
"source \"{}/upstream\"\n\ngem \"{DEP}\"; # the vulnerable one\n",
855+
server.uri()
856+
),
836857
_ => format!("source \"{}/upstream\"\n\ngem \"{DEP}\"\n", server.uri()),
837858
};
838859
std::fs::write(proj.join(gemfile_name), gemfile_body).unwrap();
@@ -984,7 +1005,9 @@ async fn redirect_scanned_project(
9841005
| Driver::ScanVexConditionalDeclaration
9851006
| Driver::ScanVexScopedConstantModifier
9861007
| Driver::ScanVexHeredocDeclaration
987-
| Driver::ScanVexInterpolatedHeredocDeclaration => vec![
1008+
| Driver::ScanVexInterpolatedHeredocDeclaration
1009+
| Driver::ScanVexSemicolonJoinedDeclaration
1010+
| Driver::ScanVexTrailingSemicolonDeclaration => vec![
9881011
"scan",
9891012
"--mode",
9901013
"hosted",
@@ -1060,7 +1083,8 @@ async fn redirect_scanned_project(
10601083
| Driver::ScanVexConditionalDeclaration
10611084
| Driver::ScanVexScopedConstantModifier
10621085
| Driver::ScanVexHeredocDeclaration
1063-
| Driver::ScanVexInterpolatedHeredocDeclaration => {
1086+
| Driver::ScanVexInterpolatedHeredocDeclaration
1087+
| Driver::ScanVexSemicolonJoinedDeclaration => {
10641088
Some("redirect_gem_unrecognized_declaration")
10651089
}
10661090
_ => None,
@@ -1147,7 +1171,7 @@ async fn redirect_scanned_project(
11471171
);
11481172
}
11491173
match driver {
1150-
Driver::ScanVex => {
1174+
Driver::ScanVex | Driver::ScanVexTrailingSemicolonDeclaration => {
11511175
assert_eq!(env["vex"]["statements"], 1, "vex block: {env}");
11521176
assert_eq!(
11531177
env["vex"]["verified"], false,
@@ -1168,7 +1192,8 @@ async fn redirect_scanned_project(
11681192
| Driver::ScanVexConditionalDeclaration
11691193
| Driver::ScanVexScopedConstantModifier
11701194
| Driver::ScanVexHeredocDeclaration
1171-
| Driver::ScanVexInterpolatedHeredocDeclaration => {
1195+
| Driver::ScanVexInterpolatedHeredocDeclaration
1196+
| Driver::ScanVexSemicolonJoinedDeclaration => {
11721197
unreachable!("asserted and returned above")
11731198
}
11741199
Driver::GetUuid => {
@@ -1956,6 +1981,53 @@ async fn gem_hosted_multi_line_declaration_is_refused_and_still_installs() {
19561981
assert!(fx.is_none(), "the multi-line driver asserts in place");
19571982
}
19581983

1984+
/// #826: `gem "x"; gem "y"` must not be rewritten. The line rewrite
1985+
/// deleted `gem "y"`, so the next frozen install failed.
1986+
#[tokio::test(flavor = "multi_thread")]
1987+
#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17); \
1988+
run with a pinned toolchain via --ignored"]
1989+
async fn gem_hosted_semicolon_joined_declarations_are_refused_and_still_install() {
1990+
let fx = redirect_scanned_project(
1991+
"semicolon-joined",
1992+
Spelling::Gemfile,
1993+
false,
1994+
true,
1995+
None,
1996+
Driver::ScanVexSemicolonJoinedDeclaration,
1997+
)
1998+
.await;
1999+
assert!(fx.is_none(), "the `;`-joined driver asserts in place");
2000+
}
2001+
2002+
/// #826: `gem "x"; # c` is a complete declaration. Since #637 it was
2003+
/// refused as continuing on the next line; it must redirect, and a fresh
2004+
/// checkout must install the patched bytes.
2005+
#[tokio::test(flavor = "multi_thread")]
2006+
#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17); \
2007+
run with a pinned toolchain via --ignored"]
2008+
async fn gem_hosted_trailing_semicolon_declaration_redirects_and_installs() {
2009+
let Some(fx) = redirect_scanned_project(
2010+
"trailing-semicolon",
2011+
Spelling::Gemfile,
2012+
false,
2013+
true,
2014+
None,
2015+
Driver::ScanVexTrailingSemicolonDeclaration,
2016+
)
2017+
.await
2018+
else {
2019+
return;
2020+
};
2021+
let (fresh, install) = fresh_checkout_bundle_install(&fx);
2022+
assert!(
2023+
install.status.success(),
2024+
"fresh-checkout `bundle install` must succeed from the patch registry.\nstdout:\n{}\nstderr:\n{}",
2025+
String::from_utf8_lossy(&install.stdout),
2026+
String::from_utf8_lossy(&install.stderr),
2027+
);
2028+
assert_patched_install(&fx, &fresh);
2029+
}
2030+
19592031
/// #340: a `gem` declaration with an `if` modifier must not be rewritten
19602032
/// (the rewrite dropped the condition and declared the gem unconditionally).
19612033
#[tokio::test(flavor = "multi_thread")]

‎crates/socket-patch-core/src/formats/gem/gemfile.rs‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ pub(crate) struct SourceOption {
243243
/// it may carry one. Positional arguments (`*V`, constants, method calls)
244244
/// are version constraints and never do.
245245
pub(crate) fn source_option(tail: &str) -> Option<SourceOption> {
246+
let tail = &without_statement_end(tail);
246247
let Some(args) = args(tail) else {
247248
return Some(SourceOption {
248249
key: tail.trim().to_string(),
@@ -273,6 +274,7 @@ pub(crate) fn source_option(tail: &str) -> Option<SourceOption> {
273274
/// `path:`. Empty when the line carries none; bails to empty on an
274275
/// unparseable tail (unbalanced quote or bracket).
275276
pub(crate) fn trailing_options(tail: &str) -> String {
277+
let tail = &without_statement_end(tail);
276278
let Some(args) = args(tail) else {
277279
return String::new();
278280
};
@@ -284,6 +286,43 @@ pub(crate) fn trailing_options(tail: &str) -> String {
284286
.unwrap_or_default()
285287
}
286288

289+
/// `opts` minus a top-level `;` statement terminator (and any extra `;`s),
290+
/// keeping a trailing `#` comment. Both rewriters first refuse a tail where
291+
/// another statement follows the `;` (`gem_line_tail_blocks_edit`), so only
292+
/// `;`s, whitespace and a comment can follow it here (#826).
293+
fn without_statement_end(opts: &str) -> String {
294+
let mut quote: Option<char> = None;
295+
let mut depth: i64 = 0;
296+
let mut chars = opts.char_indices();
297+
while let Some((i, c)) = chars.next() {
298+
if let Some(q) = quote {
299+
if c == '\\' {
300+
chars.next();
301+
} else if c == q {
302+
quote = None;
303+
}
304+
continue;
305+
}
306+
match c {
307+
'#' => break,
308+
'"' | '\'' => quote = Some(c),
309+
'(' | '[' | '{' => depth += 1,
310+
')' | ']' | '}' => depth -= 1,
311+
';' if depth == 0 => {
312+
let code = opts[..i].trim_end();
313+
let rest = opts[i..].trim_start_matches(|c: char| c == ';' || c.is_whitespace());
314+
return if rest.is_empty() {
315+
code.to_string()
316+
} else {
317+
format!("{code} {rest}")
318+
};
319+
}
320+
_ => {}
321+
}
322+
}
323+
opts.to_string()
324+
}
325+
287326
#[cfg(test)]
288327
mod tests {
289328
use super::*;
@@ -397,4 +436,30 @@ mod tests {
397436
"require: \"a,b\", group: [:x, :y]"
398437
);
399438
}
439+
440+
/// #826: a bare `;` ending the statement is not part of the options
441+
/// (it would otherwise read as a positional `"7.0";` and be kept).
442+
#[test]
443+
fn trailing_options_drop_the_statement_terminator() {
444+
// Nor is it an unreadable tail, which would fail closed as a
445+
// source-selecting option.
446+
for tail in [";", "; # c", ", \"0.8.1\";", ", require: false; # c"] {
447+
assert_eq!(key(tail), None, "{tail:?}");
448+
}
449+
assert_eq!(key(", git: \"x\";"), Some("git:".into()));
450+
for (tail, opts) in [
451+
(", \"0.8.1\";", ""),
452+
(", \"0.8.1\"; # c", ""),
453+
(", require: false;", "require: false"),
454+
(
455+
", require: false ;; # lazy; ok",
456+
"require: false # lazy; ok",
457+
),
458+
(", require: \"a;b\";", "require: \"a;b\""),
459+
(", require: \"a;b\"", "require: \"a;b\""),
460+
(", require: false # x;", "require: false # x;"),
461+
] {
462+
assert_eq!(trailing_options(tail), opts, "{tail:?}");
463+
}
464+
}
400465
}

‎crates/socket-patch-core/src/patch/redirect/mod.rs‎

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5651,7 +5651,9 @@ fn gem_source_option_detail(dep: &DepOverride, what: &str, socket_vendored: bool
56515651
/// after the rewrite, and bundler refuses the Gemfile;
56525652
/// - a modifier (`if` / `unless` / `while` / `until` / `rescue` / `and` /
56535653
/// `or`) or a `do` block would be dropped, silently changing when the gem
5654-
/// is declared.
5654+
/// is declared;
5655+
/// - another statement after a top-level `;` would be deleted with the line
5656+
/// (#826). A bare trailing `;` ends the declaration and is fine.
56555657
///
56565658
/// Only code outside ordinary string literals and before a `#` comment
56575659
/// counts, so a keyword or `,` inside `require: "…"` or a comment is fine.
@@ -5684,6 +5686,18 @@ pub(crate) fn gem_line_tail_blocks_edit(tail: &str) -> Option<String> {
56845686
}
56855687
match c {
56865688
'#' => break,
5689+
// A top-level `;` ends the declaration's statement. Anything
5690+
// after it but more `;`s or a comment is another statement on
5691+
// the line the rewrite replaces, so it would be deleted (#826).
5692+
';' if depth == 0 => {
5693+
let rest = chars
5694+
.as_str()
5695+
.trim_start_matches(|c: char| c == ';' || c.is_whitespace());
5696+
if rest.is_empty() || rest.starts_with('#') {
5697+
break;
5698+
}
5699+
return Some("another statement follows the declaration on its line".to_string());
5700+
}
56875701
'"' | '\'' => quote = Some(c),
56885702
'(' | '[' | '{' => depth += 1,
56895703
')' | ']' | '}' => depth -= 1,
@@ -13915,6 +13929,113 @@ mod tests {
1391513929
}
1391613930
}
1391713931

13932+
/// #826: a top-level `;` ends the declaration's statement. Another
13933+
/// statement after it (`gem "a", "1"; gem "b", "2"`) shares the line the
13934+
/// rewrite replaces, so it would be deleted: refuse. A bare trailing
13935+
/// `;` (optionally before a comment) ends nothing else, so it is a
13936+
/// complete one-line declaration, not a continuation.
13937+
#[test]
13938+
fn gem_line_tail_semicolon_statements() {
13939+
for tail in [
13940+
", \"0.8.1\"; gem \"rainbow\", \"3.1.1\"",
13941+
", \"0.8.1\";gem \"rainbow\"",
13942+
", require: false; gem \"rainbow\" # c",
13943+
";gem \"rainbow\"",
13944+
", \"0.8.1\"; ; puts 1",
13945+
] {
13946+
let reason = gem_line_tail_blocks_edit(tail);
13947+
assert!(
13948+
reason
13949+
.as_deref()
13950+
.is_some_and(|r| r.contains("another statement")),
13951+
"{tail:?}: {reason:?}"
13952+
);
13953+
}
13954+
for tail in [
13955+
", \"0.8.1\";",
13956+
", \"0.8.1\"; ",
13957+
", \"0.8.1\"; # c",
13958+
", \"0.8.1\";; ",
13959+
", require: false;",
13960+
", require: \"a;b\"",
13961+
", require: \"a\" # x; gem \"b\"",
13962+
";",
13963+
] {
13964+
assert_eq!(gem_line_tail_blocks_edit(tail), None, "{tail:?}");
13965+
}
13966+
}
13967+
13968+
/// #826: the hosted rewrite replaces the whole physical line, so a
13969+
/// second `;`-joined declaration on it must refuse instead of being
13970+
/// deleted (the next `bundle install` would drop that dependency).
13971+
#[test]
13972+
fn gemfile_semicolon_joined_declarations_fail_closed() {
13973+
let lock = "GEM\n remote: https://rubygems.org/\n specs:\n rainbow (3.1.1)\n \
13974+
vuln-gem (1.0.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n rainbow (= 3.1.1)\n \
13975+
vuln-gem (= 1.0.0)\n\nBUNDLED WITH\n 4.0.17\n";
13976+
for decl in [
13977+
"gem \"vuln-gem\", \"1.0.0\"; gem \"rainbow\", \"3.1.1\"",
13978+
"gem \"vuln-gem\", \"1.0.0\";gem \"rainbow\", \"3.1.1\" # pair",
13979+
"gem \"vuln-gem\", require: false; gem \"rainbow\", \"3.1.1\"",
13980+
] {
13981+
let gemfile = format!("source \"https://rubygems.org\"\n\n{decl}\n");
13982+
let files = BTreeMap::from([
13983+
("Gemfile".to_string(), gemfile),
13984+
("Gemfile.lock".to_string(), lock.to_string()),
13985+
]);
13986+
let r = rewrite_registry_redirect(&files, &[gem_override("vuln-gem", "1.0.0")]);
13987+
assert!(
13988+
r.files.is_empty() && r.edits.is_empty(),
13989+
"{decl:?} must not be rewritten: files={:?}",
13990+
r.files
13991+
);
13992+
assert_eq!(
13993+
warning_codes(&r),
13994+
vec!["redirect_gem_unrecognized_declaration"],
13995+
"{decl:?}: {:?}",
13996+
r.warnings
13997+
);
13998+
}
13999+
}
14000+
14001+
/// #826 (the #637 regression): a declaration ending in a bare `;`,
14002+
/// with or without a trailing comment, is complete and still rewrites.
14003+
#[test]
14004+
fn gemfile_trailing_semicolon_declaration_rewrites() {
14005+
let lock = "GEM\n remote: https://rubygems.org/\n specs:\n vuln-gem (1.0.0)\n\n\
14006+
PLATFORMS\n ruby\n\nDEPENDENCIES\n vuln-gem\n\n\
14007+
BUNDLED WITH\n 4.0.17\n";
14008+
for (decl, want) in [
14009+
(
14010+
"gem \"vuln-gem\", \"1.0.0\";",
14011+
" gem \"vuln-gem\", \"1.0.0\"\nend",
14012+
),
14013+
(
14014+
"gem \"vuln-gem\", \"1.0.0\"; # c",
14015+
" gem \"vuln-gem\", \"1.0.0\"\nend",
14016+
),
14017+
(
14018+
"gem \"vuln-gem\", require: false;",
14019+
" gem \"vuln-gem\", \"1.0.0\", require: false\nend",
14020+
),
14021+
] {
14022+
let gemfile = format!("source \"https://rubygems.org\"\n\n{decl}\n");
14023+
let files = BTreeMap::from([
14024+
("Gemfile".to_string(), gemfile),
14025+
("Gemfile.lock".to_string(), lock.to_string()),
14026+
]);
14027+
let r = rewrite_registry_redirect(&files, &[gem_override("vuln-gem", "1.0.0")]);
14028+
assert!(
14029+
!warning_codes(&r).contains(&"redirect_gem_unrecognized_declaration"),
14030+
"{decl:?}: {:?}",
14031+
r.warnings
14032+
);
14033+
let out = r.files.get("Gemfile").expect("declaration rewritten");
14034+
assert!(out.contains(want), "{decl:?}: {out}");
14035+
assert!(!out.contains(';'), "{decl:?}: {out}");
14036+
}
14037+
}
14038+
1391814039
/// Control for #340: single-line declarations whose tails merely look
1391914040
/// like the refused shapes (a keyword inside a string or a comment, a
1392014041
/// symbol or a key named like a keyword, a closed bracket) still

0 commit comments

Comments
 (0)