Skip to content

Commit 5d814c7

Browse files
committed
Test hosted gem refusals with real Bundler
Covers a gem declared in two group blocks (#548) and a direct dependency declared through eval_gemfile (#482): the hosted scan must leave the Gemfile pair untouched, attest nothing, and Bundler must still install the project. Assisted-by: Claude Code:claude-opus-5-5
1 parent 94270b4 commit 5d814c7

1 file changed

Lines changed: 130 additions & 7 deletions

File tree

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

Lines changed: 130 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,17 @@ enum Driver {
405405
/// `Gemfile.next`, so the run must redirect nothing and attest nothing.
406406
/// The fixture asserts that contract itself and yields `None`.
407407
ScanVexDualBoot,
408+
/// [`Driver::ScanVex`] on a Gemfile that declares the gem in two `group`
409+
/// blocks (#548): bundler accepts the duplicate, but rewriting only one
410+
/// declaration would leave conflicting requirements. The run must
411+
/// refuse, write nothing and attest nothing; the fixture asserts that
412+
/// and yields `None`.
413+
ScanVexDuplicateDeclaration,
414+
/// [`Driver::ScanVex`] on a Gemfile that declares the gem through
415+
/// `eval_gemfile` (#482): the lock lists it as a direct dependency, so
416+
/// appending a source block would declare it twice. Same contract as
417+
/// [`Driver::ScanVexDuplicateDeclaration`].
418+
ScanVexEvalGemfile,
408419
}
409420

410421
impl Driver {
@@ -413,6 +424,8 @@ impl Driver {
413424
Driver::ScanVex => "scan --mode hosted",
414425
Driver::GetUuid => "get <uuid> --mode hosted",
415426
Driver::ScanVexDualBoot => "scan --mode hosted (BUNDLE_GEMFILE=Gemfile.next)",
427+
Driver::ScanVexDuplicateDeclaration => "scan --mode hosted (gem in two groups)",
428+
Driver::ScanVexEvalGemfile => "scan --mode hosted (gem via eval_gemfile)",
416429
}
417430
}
418431
}
@@ -675,11 +688,22 @@ async fn redirect_scanned_project(
675688
// 3. The fixture project, installed from the MOCK upstream (hermetic).
676689
let proj = tmp.path().join("proj");
677690
std::fs::create_dir_all(&proj).unwrap();
678-
std::fs::write(
679-
proj.join(gemfile_name),
680-
format!("source \"{}/upstream\"\n\ngem \"{DEP}\"\n", server.uri()),
681-
)
682-
.unwrap();
691+
let gemfile_body = match driver {
692+
Driver::ScanVexDuplicateDeclaration => format!(
693+
"source \"{}/upstream\"\n\ngroup :development do\n gem \"{DEP}\"\nend\n\n\
694+
group :test do\n gem \"{DEP}\"\nend\n",
695+
server.uri()
696+
),
697+
Driver::ScanVexEvalGemfile => {
698+
std::fs::write(proj.join("Gemfile.common"), format!("gem \"{DEP}\"\n")).unwrap();
699+
format!(
700+
"source \"{}/upstream\"\n\neval_gemfile \"Gemfile.common\"\n",
701+
server.uri()
702+
)
703+
}
704+
_ => format!("source \"{}/upstream\"\n\ngem \"{DEP}\"\n", server.uri()),
705+
};
706+
std::fs::write(proj.join(gemfile_name), gemfile_body).unwrap();
683707
let config_args = bundler.config_local_args("path", "vendor/bundle");
684708
let config_args: Vec<&str> = config_args.iter().map(String::as_str).collect();
685709
let config = bundle(&proj, &config_args);
@@ -775,7 +799,10 @@ async fn redirect_scanned_project(
775799
);
776800
}
777801
let argv: Vec<&str> = match driver {
778-
Driver::ScanVex | Driver::ScanVexDualBoot => vec![
802+
Driver::ScanVex
803+
| Driver::ScanVexDualBoot
804+
| Driver::ScanVexDuplicateDeclaration
805+
| Driver::ScanVexEvalGemfile => vec![
779806
"scan",
780807
"--mode",
781808
"hosted",
@@ -825,6 +852,21 @@ async fn redirect_scanned_project(
825852
assert_dual_boot_redirects_nothing(&env, &proj, &pristine_gemfile, &pristine_lock);
826853
return None;
827854
}
855+
if let Some(warning) = match driver {
856+
Driver::ScanVexDuplicateDeclaration => Some("redirect_gem_declared_more_than_once"),
857+
Driver::ScanVexEvalGemfile => Some("redirect_gem_declaration_not_visible"),
858+
_ => None,
859+
} {
860+
assert_unwirable_declaration_redirects_nothing(
861+
&proj,
862+
&bundler,
863+
warning,
864+
(code, &stdout, &stderr),
865+
&pristine_gemfile,
866+
&pristine_lock,
867+
);
868+
return None;
869+
}
828870
assert_eq!(
829871
code,
830872
0,
@@ -898,7 +940,9 @@ async fn redirect_scanned_project(
898940
"in-run hosted VEX is attested from this run's fetched record, not hash-verified: {env}"
899941
);
900942
}
901-
Driver::ScanVexDualBoot => unreachable!("asserted and returned above"),
943+
Driver::ScanVexDualBoot
944+
| Driver::ScanVexDuplicateDeclaration
945+
| Driver::ScanVexEvalGemfile => unreachable!("asserted and returned above"),
902946
Driver::GetUuid => {
903947
// get's hosted envelope (CLI_CONTRACT.md "get --mode and
904948
// installed narrowing"): `found` counts the resolved patch;
@@ -952,6 +996,49 @@ async fn redirect_scanned_project(
952996
})
953997
}
954998

999+
/// #482 / #548: a Gemfile whose declarations of the gem the rewriter cannot
1000+
/// edit as one (two `group` blocks, an `eval_gemfile`d file). The scan names
1001+
/// the refusal, leaves the pair byte-identical and attests nothing, and the
1002+
/// real bundler still installs the project (before the fix the Gemfile was
1003+
/// left declaring the gem twice and every install exited 4).
1004+
fn assert_unwirable_declaration_redirects_nothing(
1005+
proj: &Path,
1006+
bundler: &bundler_e2e::Bundler,
1007+
warning: &str,
1008+
(code, stdout, stderr): (i32, &str, &str),
1009+
pristine_gemfile: &[u8],
1010+
pristine_lock: &[u8],
1011+
) {
1012+
let env: serde_json::Value = serde_json::from_str(stdout)
1013+
.unwrap_or_else(|e| panic!("not JSON: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}"));
1014+
assert!(
1015+
stdout.contains(warning),
1016+
"the refusal must be named ({warning}): {env}"
1017+
);
1018+
assert_ne!(code, 0, "nothing was patched or attested: {env}");
1019+
assert!(
1020+
env["vex"]["statements"].as_u64().unwrap_or(0) == 0,
1021+
"nothing may be attested: {env}"
1022+
);
1023+
assert_eq!(
1024+
std::fs::read(proj.join("Gemfile")).unwrap(),
1025+
pristine_gemfile,
1026+
"the Gemfile must be byte-identical"
1027+
);
1028+
assert_eq!(
1029+
std::fs::read(proj.join("Gemfile.lock")).unwrap(),
1030+
pristine_lock,
1031+
"the lock must be byte-identical"
1032+
);
1033+
let install = bundle(proj, &["install"]);
1034+
assert!(
1035+
install.status.success(),
1036+
"bundler {} must still install the untouched project:\n{}",
1037+
bundler.version,
1038+
String::from_utf8_lossy(&install.stderr)
1039+
);
1040+
}
1041+
9551042
/// #390's contract on a `BUNDLE_GEMFILE: Gemfile.next` project: the hosted
9561043
/// scan names the setting, rewrites neither the `Gemfile` pair (which
9571044
/// bundler ignores) nor `Gemfile.next`, and its in-run VEX attests nothing.
@@ -1549,6 +1636,42 @@ async fn gem_hosted_bundle_gemfile_dual_boot_redirects_nothing() {
15491636
assert!(fx.is_none(), "the dual-boot driver asserts in place");
15501637
}
15511638

1639+
/// #548: a gem declared in two `group` blocks must not be half-rewritten
1640+
/// (bundler refuses `= 1.0.0` next to `>= 0` on every install).
1641+
#[tokio::test(flavor = "multi_thread")]
1642+
#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17; CHECKSUMS arm >= 2.6); \
1643+
the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"]
1644+
async fn gem_hosted_gem_declared_in_two_groups_is_refused_and_still_installs() {
1645+
let fx = redirect_scanned_project(
1646+
"two-groups",
1647+
Spelling::Gemfile,
1648+
false,
1649+
true,
1650+
None,
1651+
Driver::ScanVexDuplicateDeclaration,
1652+
)
1653+
.await;
1654+
assert!(fx.is_none(), "the duplicate-declaration driver asserts in place");
1655+
}
1656+
1657+
/// #482: a direct dependency declared through `eval_gemfile` must not get a
1658+
/// second, appended declaration.
1659+
#[tokio::test(flavor = "multi_thread")]
1660+
#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17; CHECKSUMS arm >= 2.6); \
1661+
the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"]
1662+
async fn gem_hosted_eval_gemfile_direct_dep_is_refused_and_still_installs() {
1663+
let fx = redirect_scanned_project(
1664+
"eval-gemfile",
1665+
Spelling::Gemfile,
1666+
false,
1667+
true,
1668+
None,
1669+
Driver::ScanVexEvalGemfile,
1670+
)
1671+
.await;
1672+
assert!(fx.is_none(), "the eval_gemfile driver asserts in place");
1673+
}
1674+
15521675
/// The compact-index DEPENDENCY contract, pinned from the red side: a patch
15531676
/// registry whose `/info` omits the gem's runtime deps (production's
15541677
/// HISTORICAL behavior until the 2026-08-18 republish fixed the served index)

0 commit comments

Comments
 (0)