Skip to content

Commit e2d9633

Browse files
Fix gem pair model ignoring custom lockfile and Bundler 1 twins (#749, #751) (#768)
* Start fix for #749, #751 Assisted-by: Claude Code:claude-opus-5-5 * Follow the gem lock and twin Bundler loads Hosted mode wired the wrong gem files in two Bundler layouts, so the scan reported success (and its VEX attested a patch) while Bundler installed the unpatched gem or frozen installs failed: - Bundler 4's custom lockfile (BUNDLE_LOCKFILE, env or .bundle/config) was ignored, so the lock Bundler reads was never pinned (#749). A lockfile naming anything but the pair's own default lock is now refused in hosted (redirect_gem_bundle_lockfile_unsupported) and vendored (gemfile_not_loaded) mode before any write. - A Gemfile + gems.rb twin always followed Bundler >= 2 and wired gems.rb, but Bundler 1.x loads the Gemfile (#751). A twin whose locks say BUNDLED WITH 1.x is now wired through the Gemfile pair, and twin locks that disagree on the major are refused (redirect_gem_twin_bundler_versions_diverge). Assisted-by: Claude Code:claude-opus-5-5 * Add real-Bundler e2e for custom lock and twins Two host capstones in e2e_redirect_gem_build: a Bundler 4 project with `lockfile custom.lock` (and a leftover Gemfile.lock) redirects and attests nothing and still installs frozen (#749), and a Bundler 1.x Gemfile + gems.rb twin is wired through the Gemfile and a fresh checkout installs the patched gem (#751). Each skips on the Bundler line it does not apply to. Assisted-by: Claude Code:claude-opus-5-5 * Drop CHANGELOG entry from this PR Release notes are written when a release is cut, from the merged PR log and the code, so PRs no longer edit CHANGELOG.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Fix vex alias tests broken by store-copy merge #605 taught the name-keyed npm resolver to probe bundled store trees, so it now finds aliased copies (node_modules/lp) and a nested host's store peers itself. Two vex_consumed tests from #738 assumed that set never held aliases, so main's CI went red after both merged. The tests now feed the alias-free set explicitly to keep covering alias expansion, and also check the resolver's own set reaches the same copies with no duplicates. No production code changes. Assisted-by: Claude Code:claude-opus-5-5 (cherry picked from commit 40dac07) * Report gem locks socket-patch cannot read Lock inventory reads only the lock bundler loads (#736), so a custom BUNDLE_LOCKFILE, an unsupported BUNDLE_GEMFILE or a Gemfile + gems.rb twin whose locks disagree on the bundler major left a lockfile-only scan with no gem entries and no warning: the per-candidate redirect refusals never ran because there were no gem candidates. Surface the reason as a gem_lock_unsupported diagnosis on the inventory's existing layout-refusal channel, which scan and the in-memory engine already turn into run-level warnings. An empty BUNDLE_LOCKFILE now shadows the tiers below it, as Settings#[] does, instead of letting a global custom lock through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ULgrJMQMWEBAsiuprY449 * Port #878's digest-helper fix to unbreak coverage utils::digest's production_digests_go_through_the_helpers fails on main: three Gradle/JVM files compute digests inline. That makes `coverage`, `test` and `test-release` red on every PR. #878 routes them through utils::digest. This is the same change, ported so this PR's CI is green. It becomes a no-op once #878 lands. Claude-Session: https://claude.ai/code/session_01LS9AJhpVngXZxng8TRA2Kd Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> (cherry picked from commit 4d8cad2) * Drop stale entries from the digest pending list The digest guard test is red on main: #955 added crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs to the pending list, and #690 had already moved them onto the utils::digest helpers. This ports the same three-line change as #1016, so it becomes a no-op once #1016 lands. (cherry picked from commit d65c5c4) * Refuse an absolute BUNDLE_LOCKFILE on a memory view The hosted memory engine has no real project root, so it anchored the BUNDLE_LOCKFILE identity check at `/`. An absolute `/Gemfile.lock` (or `/gems.locked` beside a gems.rb) then compared equal to the in-project pair's lock, and the pair was rewritten and confirmed. Bundler opens an absolute lockfile as is, never the project's own lock, so that confirmed a pin frozen installs never read. The disk path already refuses it, because it anchors at the real root. A rooted BUNDLE_LOCKFILE on a memory view is now UnsupportedLockfile (redirect_gem_bundle_lockfile_unsupported), like any other foreign lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ULgrJMQMWEBAsiuprY449 * Refuse every Gemfile + gems.rb twin instead of trusting BUNDLED WITH #751's fix picked a twin's pair from the locks' BUNDLED WITH: all 1.x meant the Gemfile pair, otherwise gems.rb. But BUNDLED WITH records which bundler wrote a lock, not which one installs it. Bundler chooses the twin's spelling from the running major: 1.x loads the Gemfile, >= 2 loads gems.rb. So bundler >= 2 installing a 1.x-stamped twin, or 1.x installing a 2.x-stamped one, read the pair the scan never pinned, while the run (and its VEX) reported the gem redirected. Lock inventory and VEX discovery used the same selector. Nothing a scan can read says which bundler runs, so a twin under default discovery is now refused everywhere, the way vendored mode already refuses it: - hosted mode: redirect_gem_twin_manifest_ambiguous (replaces the unreleased redirect_gem_twin_bundler_versions_diverge), nothing written or attested; - lock readers (lock inventory, VEX discovery): no lock, with the gem_lock_unsupported diagnosis. BUNDLE_GEMFILE naming either spelling still selects that pair, and a lone Gemfile or gems.rb pair is unchanged. The BUNDLED WITH parser this PR added is gone. Tests: the engine, lock-inventory and hosted_memory_engine twin tests now expect the refusal for 1.x, 2.x and mixed stamps. #431's default_discovery_still_prefers_gems_rb now covers a lone gems.rb. The real-Bundler e2e (gem_hosted_twin_redirects_nothing) runs on every bundler line and checks the untouched twin still installs frozen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ULgrJMQMWEBAsiuprY449 * Count gems.rb only when it is a regular file for BUNDLE_LOCKFILE The disk path decided whether the project holds a gems.rb with symlink_metadata().is_ok(), so a directory or a dangling symlink named gems.rb made gems.locked the "pair's own lock". BUNDLE_LOCKFILE: gems.locked then passed as a no-op, and Gemfile.lock was rewritten and attested while bundler 4 (whose File.file? ignores that gems.rb) installed from gems.locked. Use the same predicate as Bundler's File.file? and the lock readers' ProjectView::is_file: a regular file, through symlinks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ULgrJMQMWEBAsiuprY449 * Label the setup-php pin in ci.yml with its tag Upstream moved setup-php's v2 tag, so the "# v2" comment on the 2.37.2 commit pin now fails the Audit GitHub Actions check (ref-version-mismatch). Same change as #1118. Assisted-by: Claude Code:claude-opus-5-5 * Refuse a gem twin even if one spelling is unread A Gemfile + gems.rb twin whose other spelling is a symlink, an unreadable file or not UTF-8 was not seen as a twin by hosted mode, so the readable pair was still pinned although Bundler 2+ loads gems.rb. Lock inventory already counted such a twin and warned gem_lock_unsupported, so the scan both warned and rewrote. The twin check now counts every spelling Bundler sees, readable or not. Found by Bugbot on #768. Assisted-by: Claude Code:claude-opus-5-5 * Count an unreadable on-disk twin spelling too A Gemfile + gems.rb twin whose second spelling is a regular file the scan cannot read (permission denied, I/O error) left no trace in the read lists, so hosted mode still pinned the readable pair. The twin check now also asks the project view whether the file exists, the same File.file? test lock inventory uses. Found by Bugbot on #768. Assisted-by: Claude Code:claude-opus-5-5 * Treat a symlinked twin spelling as present Hosted file-set scans see a git symlink Gemfile or gems.rb as a symlink marker with no target. Lock inventory took that as absent, so a Gemfile + gems.rb twin was inventoried from the regular spelling's lock with no gem_lock_unsupported warning, although Bundler follows the link and may load the other pair. The twin check now fails closed on a symlinked spelling. Found by the security review on #768. Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 18c5f81 commit e2d9633

12 files changed

Lines changed: 1248 additions & 81 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

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

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,17 @@ enum Driver {
457457
/// environment, so bundler still loads `Gemfile.next` and the run must
458458
/// still redirect and attest nothing.
459459
ScanVexDualBootEnvGemfile,
460+
/// [`Driver::ScanVex`] on a bundler 4 project whose `.bundle/config`
461+
/// sets `lockfile custom.lock` beside a leftover `Gemfile.lock` (#749):
462+
/// bundler reads `custom.lock`, which the rewriter never pins, so the
463+
/// run must redirect nothing and attest nothing. Bundler >= 4 only; the
464+
/// fixture asserts the contract itself and yields `None`.
465+
ScanVexCustomLockfile,
466+
/// [`Driver::ScanVex`] on a `Gemfile` + `gems.rb` twin (#751): bundler
467+
/// 1.x loads the `Gemfile` and >= 2 loads `gems.rb`, and the scan cannot
468+
/// see which runs, so it must redirect and attest nothing and leave all
469+
/// four files byte-identical. Every bundler line.
470+
ScanVexTwin,
460471
/// [`Driver::ScanVex`] on a Gemfile that declares the gem inside a
461472
/// `group :development do … end` block (#775): hosted mode wraps it in
462473
/// a source block inside the group, but vendored mode cannot edit an
@@ -508,6 +519,8 @@ impl Driver {
508519
Driver::ScanVexDualBootEnvGemfile => {
509520
"scan --mode hosted (config Gemfile.next, env BUNDLE_GEMFILE=Gemfile)"
510521
}
522+
Driver::ScanVexCustomLockfile => "scan --mode hosted (lockfile custom.lock)",
523+
Driver::ScanVexTwin => "scan --mode hosted (Gemfile + gems.rb twin)",
511524
Driver::ScanVexGroupBlock => "scan --mode hosted (gem in a group block)",
512525
Driver::ScanVexSemicolonJoinedDeclaration => {
513526
"scan --mode hosted (two `;`-joined gem declarations)"
@@ -622,6 +635,20 @@ async fn redirect_scanned_project(
622635
let bundler = bundler_e2e::gate("e2e_redirect_gem_build", tag, floor, &|c| {
623636
cache_env::isolate(c);
624637
})?;
638+
// Drivers that only mean something on one bundler line.
639+
let only = match driver {
640+
Driver::ScanVexCustomLockfile if !bundler.at_least(4, 0) => {
641+
Some("custom lockfiles need bundler >= 4")
642+
}
643+
_ => None,
644+
};
645+
if let Some(why) = only {
646+
println!(
647+
"SKIP e2e_redirect_gem_build ({tag}): bundler {}: {why}",
648+
bundler.version
649+
);
650+
return None;
651+
}
625652

626653
let tmp = tempfile::tempdir().unwrap();
627654
let (gemfile_name, lock_name) = spelling.pair();
@@ -975,6 +1002,27 @@ async fn redirect_scanned_project(
9751002
String::from_utf8_lossy(&cfg.stderr)
9761003
);
9771004
}
1005+
let custom_lockfile = driver == Driver::ScanVexCustomLockfile;
1006+
if custom_lockfile {
1007+
// `bundle config set --local lockfile custom.lock`; the default
1008+
// lock stays behind as a leftover bundler 4 ignores.
1009+
std::fs::copy(proj.join(lock_name), proj.join("custom.lock")).unwrap();
1010+
let args = bundler.config_local_args("lockfile", "custom.lock");
1011+
let args: Vec<&str> = args.iter().map(String::as_str).collect();
1012+
let cfg = bundle(&proj, &args);
1013+
assert!(
1014+
cfg.status.success(),
1015+
"bundle config set --local lockfile failed:\n{}",
1016+
String::from_utf8_lossy(&cfg.stderr)
1017+
);
1018+
}
1019+
let twin = driver == Driver::ScanVexTwin;
1020+
if twin {
1021+
// Identical twins: which pair installs depends only on the bundler
1022+
// that runs.
1023+
std::fs::copy(proj.join(gemfile_name), proj.join("gems.rb")).unwrap();
1024+
std::fs::copy(proj.join(lock_name), proj.join("gems.locked")).unwrap();
1025+
}
9781026
// Synthetic credentials must never appear in the scan's automatic
9791027
// diagnostics. The loopback mirror itself serves the unpatched gem.
9801028
let mirror = format!("{}/upstream/", server.uri()).replacen(
@@ -1007,6 +1055,8 @@ async fn redirect_scanned_project(
10071055
| Driver::ScanVexMirrorSource
10081056
| Driver::ScanVexMirrorSourceEnv
10091057
| Driver::ScanVexMirrorAllEnv
1058+
| Driver::ScanVexCustomLockfile
1059+
| Driver::ScanVexTwin
10101060
| Driver::ScanVexDualBoot
10111061
| Driver::ScanVexDualBootEnvGemfile
10121062
| Driver::ScanVexDuplicateDeclaration
@@ -1082,6 +1132,35 @@ async fn redirect_scanned_project(
10821132
assert_dual_boot_redirects_nothing(&env, &proj, &pristine_gemfile, &pristine_lock);
10831133
return None;
10841134
}
1135+
if custom_lockfile {
1136+
assert_custom_lockfile_redirects_nothing(
1137+
&bundler,
1138+
"redirect_gem_bundle_lockfile_unsupported",
1139+
(code, &stdout, &stderr),
1140+
&proj,
1141+
&[
1142+
("Gemfile", &pristine_gemfile),
1143+
("Gemfile.lock", &pristine_lock),
1144+
("custom.lock", &pristine_lock),
1145+
],
1146+
);
1147+
return None;
1148+
}
1149+
if twin {
1150+
assert_custom_lockfile_redirects_nothing(
1151+
&bundler,
1152+
"redirect_gem_twin_manifest_ambiguous",
1153+
(code, &stdout, &stderr),
1154+
&proj,
1155+
&[
1156+
("Gemfile", &pristine_gemfile),
1157+
("Gemfile.lock", &pristine_lock),
1158+
("gems.rb", &pristine_gemfile),
1159+
("gems.locked", &pristine_lock),
1160+
],
1161+
);
1162+
return None;
1163+
}
10851164
if let Some(warning) = match driver {
10861165
Driver::ScanVexDuplicateDeclaration => Some("redirect_gem_declared_more_than_once"),
10871166
Driver::ScanVexEvalGemfile => Some("redirect_gem_declaration_not_visible"),
@@ -1194,6 +1273,8 @@ async fn redirect_scanned_project(
11941273
}
11951274
Driver::ScanVexDualBoot
11961275
| Driver::ScanVexDualBootEnvGemfile
1276+
| Driver::ScanVexCustomLockfile
1277+
| Driver::ScanVexTwin
11971278
| Driver::ScanVexDuplicateDeclaration
11981279
| Driver::ScanVexEvalGemfile
11991280
| Driver::ScanVexMirrorAll
@@ -1306,6 +1387,56 @@ fn assert_unwirable_declaration_redirects_nothing(
13061387
);
13071388
}
13081389

1390+
/// The contract of a hosted scan that must refuse every gem: #749's
1391+
/// `custom.lock` named in `.bundle/config` (bundler 4), or #751's
1392+
/// `Gemfile` + `gems.rb` twin. The scan reports `refusal`, redirects and
1393+
/// attests nothing, leaves every one of `files` byte-identical, and bundler
1394+
/// still installs the untouched project frozen.
1395+
fn assert_custom_lockfile_redirects_nothing(
1396+
bundler: &bundler_e2e::Bundler,
1397+
refusal: &str,
1398+
(code, stdout, stderr): (i32, &str, &str),
1399+
proj: &Path,
1400+
files: &[(&str, &[u8])],
1401+
) {
1402+
let env: serde_json::Value = serde_json::from_str(stdout)
1403+
.unwrap_or_else(|e| panic!("not JSON: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}"));
1404+
let warning_codes: Vec<&str> = env["redirect"]["warnings"]
1405+
.as_array()
1406+
.map(|a| a.iter().filter_map(|w| w["code"].as_str()).collect())
1407+
.unwrap_or_default();
1408+
assert!(
1409+
warning_codes.contains(&refusal),
1410+
"the {refusal} refusal must be reported: {env}"
1411+
);
1412+
assert_ne!(code, 0, "nothing was patched or attested: {env}");
1413+
assert_eq!(
1414+
env["redirect"]["redirected"], 0,
1415+
"nothing redirected: {env}"
1416+
);
1417+
assert!(
1418+
env["vex"]["statements"].as_u64().unwrap_or(0) == 0,
1419+
"no in-run attestation for a lock that was never pinned: {env}"
1420+
);
1421+
for (file, want) in files {
1422+
assert_eq!(
1423+
std::fs::read(proj.join(file)).unwrap(),
1424+
*want,
1425+
"{file} must be byte-untouched"
1426+
);
1427+
}
1428+
let args = bundler.config_local_args("frozen", "true");
1429+
let args: Vec<&str> = args.iter().map(String::as_str).collect();
1430+
assert!(bundle(proj, &args).status.success());
1431+
let install = bundle(proj, &["install"]);
1432+
assert!(
1433+
install.status.success(),
1434+
"bundler {} must still install the untouched project frozen:\n{}",
1435+
bundler.version,
1436+
String::from_utf8_lossy(&install.stderr)
1437+
);
1438+
}
1439+
13091440
/// #390's contract on a `BUNDLE_GEMFILE: Gemfile.next` project: the hosted
13101441
/// scan names the setting, rewrites neither the `Gemfile` pair (which
13111442
/// bundler ignores) nor `Gemfile.next`, and its in-run VEX attests nothing.
@@ -2065,6 +2196,49 @@ async fn gem_hosted_bundle_gemfile_dual_boot_redirects_nothing() {
20652196
assert!(fx.is_none(), "the dual-boot driver asserts in place");
20662197
}
20672198

2199+
/// #749: bundler 4's `bundle config set --local lockfile custom.lock`
2200+
/// makes bundler read `custom.lock`. The hosted scan used to wire the
2201+
/// Gemfile (and the ignored leftover `Gemfile.lock`), report success and
2202+
/// attest, while every frozen install then failed; it must redirect and
2203+
/// attest nothing.
2204+
#[tokio::test(flavor = "multi_thread")]
2205+
#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 4.0 for this arm); \
2206+
the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"]
2207+
async fn gem_hosted_bundler4_custom_lockfile_redirects_nothing() {
2208+
let fx = redirect_scanned_project(
2209+
"custom-lockfile",
2210+
Spelling::Gemfile,
2211+
true,
2212+
true,
2213+
None,
2214+
Driver::ScanVexCustomLockfile,
2215+
)
2216+
.await;
2217+
assert!(fx.is_none(), "the custom-lockfile driver asserts in place");
2218+
}
2219+
2220+
/// #751: bundler 1.x loads a twin's `Gemfile` and bundler >= 2 its
2221+
/// `gems.rb`. The hosted scan used to wire `gems.rb` and attest while
2222+
/// bundler 1.17 installed the unpatched gem from the `Gemfile`; since a
2223+
/// lock's `BUNDLED WITH` does not say which bundler installs, it must
2224+
/// refuse the twin on every bundler line, and the untouched twin must
2225+
/// still install.
2226+
#[tokio::test(flavor = "multi_thread")]
2227+
#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17); \
2228+
the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"]
2229+
async fn gem_hosted_twin_redirects_nothing() {
2230+
let fx = redirect_scanned_project(
2231+
"twin",
2232+
Spelling::Gemfile,
2233+
false,
2234+
true,
2235+
None,
2236+
Driver::ScanVexTwin,
2237+
)
2238+
.await;
2239+
assert!(fx.is_none(), "the twin driver asserts in place");
2240+
}
2241+
20682242
/// #548: a gem declared in two `group` blocks must not be half-rewritten
20692243
/// (bundler refuses `= 1.0.0` next to `>= 0` on every install).
20702244
#[tokio::test(flavor = "multi_thread")]

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

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,6 +991,150 @@ async fn a_vlt_project_is_withheld_as_offline() {
991991
assert!(output.changed_files.is_empty());
992992
}
993993

994+
const GEM_BASIC_FIXTURE: &str = "redirect/gem/bundler/basic";
995+
996+
/// The gem fixture's API mocks and input files.
997+
async fn gem_server_and_input() -> (MockServer, BTreeMap<String, Vec<u8>>) {
998+
let server = MockServer::start().await;
999+
let patches = patches_from_overrides(
1000+
&fixtures_root()
1001+
.join(GEM_BASIC_FIXTURE)
1002+
.join("overrides.json"),
1003+
None,
1004+
);
1005+
mount_api(&server, &patches).await;
1006+
let input = fixture_files(&fixtures_root().join(GEM_BASIC_FIXTURE).join("input"));
1007+
(server, input)
1008+
}
1009+
1010+
fn warning_codes(redirect: &Value) -> Vec<String> {
1011+
redirect["warnings"]
1012+
.as_array()
1013+
.unwrap()
1014+
.iter()
1015+
.map(|w| w["code"].as_str().unwrap().to_string())
1016+
.collect()
1017+
}
1018+
1019+
fn changed_paths(output: &HostedScanOutput) -> Vec<&str> {
1020+
output
1021+
.changed_files
1022+
.iter()
1023+
.map(|f| f.path.as_str())
1024+
.collect()
1025+
}
1026+
1027+
/// #749: bundler 4 reads the lock `bundle config set lockfile custom.lock`
1028+
/// names, which the rewriter never pins. With a leftover `Gemfile.lock`
1029+
/// beside it, the run used to rewrite that ignored lock, report success,
1030+
/// and break every frozen install; the project is refused with nothing
1031+
/// written instead. A memory tree only finds gem candidates through the
1032+
/// lock bundler loads (#736), and that lock is none of the default ones
1033+
/// here, so the project yields no gem candidate; the run reports
1034+
/// `gem_lock_unsupported` instead (the per-candidate
1035+
/// `redirect_gem_bundle_lockfile_unsupported` refusal is covered by the
1036+
/// engine unit tests).
1037+
#[tokio::test]
1038+
async fn a_bundler4_custom_lockfile_is_refused() {
1039+
let (server, input) = gem_server_and_input().await;
1040+
let mut files = input.clone();
1041+
files.insert("custom.lock".to_string(), input["Gemfile.lock"].clone());
1042+
files.insert(
1043+
".bundle/config".to_string(),
1044+
b"---\nBUNDLE_LOCKFILE: \"custom.lock\"\n".to_vec(),
1045+
);
1046+
let output = run_engine(&server, build_input(&files, &[], options(false))).await;
1047+
let project = &output.projects[0];
1048+
assert!(project.error.is_none(), "{:?}", project.error);
1049+
assert!(project.redirected.is_empty(), "{:?}", project.redirected);
1050+
assert!(
1051+
changed_paths(&output).is_empty(),
1052+
"{:?}",
1053+
changed_paths(&output)
1054+
);
1055+
assert_gem_lock_unsupported(&output);
1056+
}
1057+
1058+
/// The run says the project's gems were not scanned, rather than finding
1059+
/// none: the lock inventory's `gem_lock_unsupported` diagnosis.
1060+
fn assert_gem_lock_unsupported(output: &HostedScanOutput) {
1061+
let codes: Vec<&str> = output.warnings.iter().map(|w| w.code.as_str()).collect();
1062+
assert!(codes.contains(&"gem_lock_unsupported"), "{codes:?}");
1063+
}
1064+
1065+
/// #749: a configured lockfile naming the pair's own default lock is the
1066+
/// lock the rewriter pins anyway, so the project is wired as usual.
1067+
#[tokio::test]
1068+
async fn a_lockfile_setting_naming_the_default_lock_is_wired() {
1069+
let (server, input) = gem_server_and_input().await;
1070+
let mut files = input.clone();
1071+
files.insert(
1072+
".bundle/config".to_string(),
1073+
b"---\nBUNDLE_LOCKFILE: \"Gemfile.lock\"\n".to_vec(),
1074+
);
1075+
let output = run_engine(&server, build_input(&files, &[], options(false))).await;
1076+
let project = &output.projects[0];
1077+
assert_eq!(
1078+
project.redirected.len(),
1079+
1,
1080+
"{:?}",
1081+
warning_codes(&project.redirect)
1082+
);
1083+
assert_eq!(changed_paths(&output), vec!["Gemfile", "Gemfile.lock"]);
1084+
}
1085+
1086+
/// The fixture lock re-stamped `BUNDLED WITH <version>`.
1087+
fn bundled_with(lock: &[u8], version: &str) -> Vec<u8> {
1088+
let text = String::from_utf8(lock.to_vec()).unwrap();
1089+
let (head, _) = text.split_once("BUNDLED WITH").unwrap();
1090+
format!("{head}BUNDLED WITH\n {version}\n").into_bytes()
1091+
}
1092+
1093+
/// A `Gemfile` + `gems.rb` twin with each lock bundled by `versions`.
1094+
fn gem_twin(
1095+
input: &BTreeMap<String, Vec<u8>>,
1096+
versions: (&str, &str),
1097+
) -> BTreeMap<String, Vec<u8>> {
1098+
let mut files = BTreeMap::new();
1099+
files.insert("Gemfile".to_string(), input["Gemfile"].clone());
1100+
files.insert("gems.rb".to_string(), input["Gemfile"].clone());
1101+
files.insert(
1102+
"Gemfile.lock".to_string(),
1103+
bundled_with(&input["Gemfile.lock"], versions.0),
1104+
);
1105+
files.insert(
1106+
"gems.locked".to_string(),
1107+
bundled_with(&input["Gemfile.lock"], versions.1),
1108+
);
1109+
files
1110+
}
1111+
1112+
/// #751: bundler 1.x loads a twin's `Gemfile` and bundler >= 2 its
1113+
/// `gems.rb`, and a lock's `BUNDLED WITH` records who wrote it, not who
1114+
/// installs it, so no twin is wired whatever its locks say: refused,
1115+
/// nothing written. No lock is the one bundler loads (#736), so the
1116+
/// memory tree yields no gem candidate and the run reports
1117+
/// `gem_lock_unsupported`; the per-candidate
1118+
/// `redirect_gem_twin_manifest_ambiguous` refusal is covered by the engine
1119+
/// unit tests.
1120+
#[tokio::test]
1121+
async fn a_gemfile_gems_rb_twin_is_refused() {
1122+
let (server, input) = gem_server_and_input().await;
1123+
for versions in [
1124+
("1.17.3", "1.17.3"),
1125+
("2.6.2", "2.6.2"),
1126+
("1.17.3", "2.6.2"),
1127+
("2.6.2", "1.17.3"),
1128+
] {
1129+
let files = gem_twin(&input, versions);
1130+
let output = run_engine(&server, build_input(&files, &[], options(false))).await;
1131+
let project = &output.projects[0];
1132+
assert!(project.redirected.is_empty(), "{versions:?}");
1133+
assert!(changed_paths(&output).is_empty(), "{versions:?}");
1134+
assert_gem_lock_unsupported(&output);
1135+
}
1136+
}
1137+
9941138
/// #736: the engine's purl set comes from the lock bundler loads. A
9951139
/// `gems.rb` project's gems live in `gems.locked`; reading only
9961140
/// `Gemfile.lock` found nothing to redirect, and a leftover `Gemfile.lock`

0 commit comments

Comments
 (0)