Skip to content

Commit 7ea685b

Browse files
Fix gem lock readers ignoring gems.locked (#736) (#750)
* Start fix for #736 Assisted-by: Claude Code:claude-opus-5-5 * Read only the gem lock Bundler actually loads A gems.rb project's gems.locked was invisible to the lock inventory, ledger recovery read only Gemfile.lock, and VEX discovery read both locks. A leftover redirected Gemfile.lock beside gems.rb + gems.locked therefore made vex attest not_affected while bundle install installed the unpatched gem from gems.locked. Add one resolver for the lock Bundler loads (honouring BUNDLE_GEMFILE and the app config) and route the inventory, gem_remotes, VEX discovery and the hosted engine through it. VEX still reads the ignored twin, but any Socket wiring there is diagnosed as unattributable instead of attested. Fixes #736 Assisted-by: Claude Code:claude-opus-5-5 * Test hosted engine on a gems.rb project Assisted-by: Claude Code:claude-opus-5-5 * Avoid a single-element loop in the polyglot test Assisted-by: Claude Code:claude-opus-5-5 * Note the gem lock reader fix in the changelog 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> * Port #851: fix vex alias tests broken by store-copy merge main is red since 4646693 (#605): two commands::vex_consumed tests assumed the name-keyed resolver never returns npm-aliased copies, which #605 changed. Same tests-only change as #851; it no-ops once main carries it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Ao6g9qAnawPfNxv11f3wM --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 33ddc26 commit 7ea685b

7 files changed

Lines changed: 403 additions & 79 deletions

File tree

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

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

993+
/// #736: the engine's purl set comes from the lock bundler loads. A
994+
/// `gems.rb` project's gems live in `gems.locked`; reading only
995+
/// `Gemfile.lock` found nothing to redirect, and a leftover `Gemfile.lock`
996+
/// beside it must not change that.
997+
#[tokio::test]
998+
async fn gems_rb_project_yields_its_gem_candidates() {
999+
const GEM_FIXTURE: &str = "redirect/gem/bundler/basic";
1000+
let server = MockServer::start().await;
1001+
let patches = patches_from_overrides(
1002+
&fixtures_root().join(GEM_FIXTURE).join("overrides.json"),
1003+
None,
1004+
);
1005+
mount_api(&server, &patches).await;
1006+
let input = fixture_files(&fixtures_root().join(GEM_FIXTURE).join("input"));
1007+
let renamed = |stale_twin: bool| {
1008+
let mut files = BTreeMap::new();
1009+
files.insert("gems.rb".to_string(), input["Gemfile"].clone());
1010+
files.insert("gems.locked".to_string(), input["Gemfile.lock"].clone());
1011+
if stale_twin {
1012+
// Locks nothing the patch API knows about.
1013+
files.insert(
1014+
"Gemfile.lock".to_string(),
1015+
b"GEM\n remote: https://rubygems.org/\n specs:\n rake (13.0.0)\n\n\
1016+
PLATFORMS\n ruby\n\nDEPENDENCIES\n rake\n"
1017+
.to_vec(),
1018+
);
1019+
}
1020+
files
1021+
};
1022+
for stale_twin in [false, true] {
1023+
let output = run_engine(
1024+
&server,
1025+
build_input(&renamed(stale_twin), &[], options(true)),
1026+
)
1027+
.await;
1028+
assert_eq!(output.projects.len(), 1);
1029+
let project = &output.projects[0];
1030+
assert!(project.error.is_none(), "{:?}", project.error);
1031+
assert_eq!(
1032+
project.redirected.len(),
1033+
1,
1034+
"stale twin {stale_twin}: {}",
1035+
serde_json::to_string_pretty(&comparable(&output)).unwrap()
1036+
);
1037+
}
1038+
}
1039+
9931040
/// #718 in the in-memory engine: a yarn berry pin of a package with a `bin`
9941041
/// takes the map from the served tarball's own package.json (fetched
9951042
/// through the provider, like wheel metadata), and a tarball it cannot

‎crates/socket-patch-core/src/crawlers/ruby_crawler.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::utils::fs::{
88
entry_is_dir, home_dir, is_dir, is_file, list_dir_entries, normalize_lexically, run_blocking,
99
};
1010
use crate::utils::process::{CommandRunner, SystemCommandRunner};
11+
use crate::vendor::lock_inventory::{DiskSnapshot, ProjectView};
1112

1213
/// Ruby/RubyGems ecosystem crawler for discovering gems in Bundler vendor
1314
/// directories or global gem installation paths.
@@ -1026,6 +1027,40 @@ pub async fn bundler_loaded_manifest(root: &Path) -> crate::formats::gem::manife
10261027
.await
10271028
}
10281029

1030+
/// [`bundler_loaded_manifest`] for the project `view` shows. A disk view
1031+
/// (or a snapshot of one) reads the ambient environment and the app config
1032+
/// like bundler; a memory view has no environment, so only its own
1033+
/// `.bundle/config` counts.
1034+
pub(crate) async fn bundler_loaded_manifest_in(
1035+
view: &ProjectView<'_>,
1036+
) -> crate::formats::gem::manifest::LoadedManifest {
1037+
use crate::formats::gem::manifest;
1038+
match view {
1039+
ProjectView::Disk(root) | ProjectView::Snapshot(DiskSnapshot { root, .. }) => {
1040+
bundler_loaded_manifest(root).await
1041+
}
1042+
ProjectView::Memory(_) => {
1043+
let config = view.read_text(".bundle/config").await.ok();
1044+
let value = config.as_deref().and_then(manifest::config_gemfile);
1045+
manifest::classify(Path::new("/"), None, value.as_deref(), None)
1046+
}
1047+
}
1048+
}
1049+
1050+
/// The ONE lockfile bundler reads for the project `view` shows: the lock of
1051+
/// [`LoadedManifest::pair`](crate::formats::gem::manifest::LoadedManifest::pair)
1052+
/// — `gems.locked` when the root holds a `gems.rb` file and nothing
1053+
/// configures `BUNDLE_GEMFILE`, else `Gemfile.lock` — or `None` when
1054+
/// `BUNDLE_GEMFILE` names a manifest outside the two default pairs. Every
1055+
/// lock READER asks this (lock inventory, ledger recovery, VEX discovery),
1056+
/// so none reads a twin bundler ignores (#736).
1057+
pub(crate) async fn bundler_loaded_lock_in(view: &ProjectView<'_>) -> Option<&'static str> {
1058+
bundler_loaded_manifest_in(view)
1059+
.await
1060+
.pair(view.is_file("gems.rb"))
1061+
.map(|(_, lock)| lock)
1062+
}
1063+
10291064
/// [`bundler_loaded_manifest`] with the environment passed explicitly (hermetic
10301065
/// tests). `ignore_config` is [`bundler_ignores_config`]; `global_config` is
10311066
/// [`bundler_global_config_file`].

‎crates/socket-patch-core/src/hosted/engine.rs‎

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -664,19 +664,8 @@ const GEM_MANIFEST_FILES: [&str; 4] = ["Gemfile", "Gemfile.lock", "gems.rb", "ge
664664
///
665665
/// A memory view has no environment: only its own app config is read.
666666
async fn keep_bundler_loaded_gem_files(view: &ProjectView<'_>, out: &mut CandidateFiles) {
667-
use crate::formats::gem::manifest::{self, LoadedManifest};
668-
let loaded = match view {
669-
ProjectView::Disk(root)
670-
| ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { root, .. }) => {
671-
crate::crawlers::ruby_crawler::bundler_loaded_manifest(root).await
672-
}
673-
ProjectView::Memory(_) => {
674-
let config = view.read_text(".bundle/config").await.ok();
675-
let value = config.as_deref().and_then(manifest::config_gemfile);
676-
let root = std::path::Path::new("/");
677-
manifest::classify(root, None, value.as_deref(), None)
678-
}
679-
};
667+
use crate::formats::gem::manifest::LoadedManifest;
668+
let loaded = crate::crawlers::ruby_crawler::bundler_loaded_manifest_in(view).await;
680669
let keep: &[&str] = match &loaded {
681670
LoadedManifest::Default => return,
682671
LoadedManifest::Configured { .. } => {

‎crates/socket-patch-core/src/vendor/lock_inventory/gem.rs‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
//! `Gemfile.lock`: the registry view and the GEM remote set ledger recovery
2-
//! reads.
1+
//! The Bundler lock (`Gemfile.lock`, or `gems.locked` for a `gems.rb`
2+
//! project — whichever bundler loads): the registry view and the GEM remote
3+
//! set ledger recovery reads.
34
45
use std::path::Path;
56

7+
use crate::crawlers::ruby_crawler::bundler_loaded_lock_in;
68
pub(super) use crate::formats::gem::gem_download_url;
79
use crate::formats::gem::GemfileLock;
810
use crate::utils::fs::read_regular_to_string;
@@ -46,26 +48,33 @@ pub(super) async fn inventory_gemfile_lock_in(
4648
pub(super) async fn inventory_gemfile_lock_raw_in(
4749
view: &ProjectView<'_>,
4850
) -> Option<Vec<LockfileEntry>> {
49-
let text = view.read_text("Gemfile.lock").await.ok()?;
51+
// Only the lock bundler loads: a twin it ignores (a leftover
52+
// `Gemfile.lock` beside `gems.rb` + `gems.locked`) is not what installs.
53+
let lock = bundler_loaded_lock_in(view).await?;
54+
let text = view.read_text(lock).await.ok()?;
5055
// The shared lock model (lockfile discovery reads it too); what bundler
5156
// would refuse (`problems`) still inventories whatever parsed — this is
5257
// read-only discovery.
5358
GemfileLock::parse(&text).entries()
5459
}
5560

56-
/// The DISTINCT `GEM remote:` bases across ALL GEM sections of the
57-
/// Gemfile.lock (trailing `/` trimmed), in first-appearance order. A
58-
/// vendored gem's spec block moved into its PATH section, so which GEM
59-
/// section it came from is unrecoverable — ledger recovery may only build
60-
/// a download URL when the lock's GEM sources agree on a single remote.
61+
/// The DISTINCT `GEM remote:` bases across ALL GEM sections of the lock
62+
/// bundler loads ([`bundler_loaded_lock_in`]; trailing `/` trimmed), in
63+
/// first-appearance order. A vendored gem's spec block moved into its PATH
64+
/// section, so which GEM section it came from is unrecoverable — ledger
65+
/// recovery may only build a download URL when the lock's GEM sources
66+
/// agree on a single remote.
6167
/// Collected scheme-AGNOSTICALLY: a non-http remote (a `file://` gem repo —
6268
/// bundler 4.0.15 locks one GEM section per `source "file://…" do` block)
6369
/// still counts toward the ambiguity decision; filtering it out first would
6470
/// collapse a mixed http+file lock to one "agreed" remote and send the
6571
/// file-sourced gem's name to the http one. The caller requires the single
6672
/// survivor to be http(s).
6773
pub(super) async fn gem_remotes(project_root: &Path) -> Vec<String> {
68-
let Ok(text) = read_regular_to_string(&project_root.join("Gemfile.lock")).await else {
74+
let Some(lock) = bundler_loaded_lock_in(&ProjectView::Disk(project_root)).await else {
75+
return Vec::new();
76+
};
77+
let Ok(text) = read_regular_to_string(&project_root.join(lock)).await else {
6978
return Vec::new();
7079
};
7180
GemfileLock::parse(&text)

‎crates/socket-patch-core/src/vendor/lock_inventory/tests.rs‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,6 +1460,168 @@ async fn gemfile_lock_legacy_multi_remote_section_is_discovery_only() {
14601460
assert_eq!(rack.integrity, LockIntegrity::Sha256Hex("c".repeat(64)));
14611461
}
14621462

1463+
/// A one-gem `GEM` lock on `remote` locking `rack (version)`.
1464+
fn rack_lock(remote: &str, version: &str) -> String {
1465+
format!(
1466+
"GEM\n remote: {remote}\n specs:\n rack ({version})\n\n\
1467+
PLATFORMS\n ruby\n\nDEPENDENCIES\n rack (= {version})\n\n\
1468+
BUNDLED WITH\n 2.6.9\n"
1469+
)
1470+
}
1471+
1472+
fn gem_purls(entries: &[LockfileEntry]) -> Vec<String> {
1473+
let mut out: Vec<String> = entries
1474+
.iter()
1475+
.filter(|e| e.purl.starts_with("pkg:gem/"))
1476+
.map(|e| e.purl.clone())
1477+
.collect();
1478+
out.sort();
1479+
out
1480+
}
1481+
1482+
/// #736: bundler loads `gems.rb` + `gems.locked` when the root holds a
1483+
/// `gems.rb` (and nothing configures `BUNDLE_GEMFILE`), so the inventory
1484+
/// reads `gems.locked`. A leftover `Gemfile.lock` beside it is a lock
1485+
/// bundler ignores and must not stand in for it.
1486+
#[tokio::test]
1487+
async fn gem_inventory_reads_the_lock_bundler_loads() {
1488+
let tmp = tempfile::tempdir().unwrap();
1489+
let root = tmp.path();
1490+
write(
1491+
root,
1492+
"gems.rb",
1493+
"source \"https://rubygems.org\"\ngem \"rack\", \"2.2.8\"\n",
1494+
)
1495+
.await;
1496+
write(
1497+
root,
1498+
"gems.locked",
1499+
&rack_lock("https://rubygems.org/", "2.2.8"),
1500+
)
1501+
.await;
1502+
assert_eq!(
1503+
gem_purls(&inventory_project(root).await),
1504+
vec!["pkg:gem/rack@2.2.8"],
1505+
"a gems.rb project's gems.locked is inventoried"
1506+
);
1507+
1508+
// The stale twin from before the project moved to gems.rb.
1509+
write(
1510+
root,
1511+
"Gemfile.lock",
1512+
&rack_lock("https://rubygems.org/", "2.0.0"),
1513+
)
1514+
.await;
1515+
assert_eq!(
1516+
gem_purls(&inventory_project(root).await),
1517+
vec!["pkg:gem/rack@2.2.8"],
1518+
"the ignored Gemfile.lock is not read"
1519+
);
1520+
assert_eq!(
1521+
gem_purls(&inventory_project_every_lock(root).await),
1522+
vec!["pkg:gem/rack@2.2.8"],
1523+
"nor by the every-instance view"
1524+
);
1525+
1526+
// `bundle config set --local gemfile Gemfile` beside the gems.rb:
1527+
// bundler now loads Gemfile + Gemfile.lock.
1528+
write(
1529+
root,
1530+
"Gemfile",
1531+
"source \"https://rubygems.org\"\ngem \"rack\"\n",
1532+
)
1533+
.await;
1534+
tokio::fs::create_dir_all(root.join(".bundle"))
1535+
.await
1536+
.unwrap();
1537+
write(root, ".bundle/config", "---\nBUNDLE_GEMFILE: \"Gemfile\"\n").await;
1538+
assert_eq!(
1539+
gem_purls(&inventory_project(root).await),
1540+
vec!["pkg:gem/rack@2.0.0"],
1541+
"BUNDLE_GEMFILE in the app config selects Gemfile.lock"
1542+
);
1543+
1544+
// A BUNDLE_GEMFILE naming some other manifest: neither root lock is
1545+
// what bundler reads.
1546+
write(
1547+
root,
1548+
".bundle/config",
1549+
"---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n",
1550+
)
1551+
.await;
1552+
assert_eq!(
1553+
gem_purls(&inventory_project(root).await),
1554+
Vec::<String>::new()
1555+
);
1556+
}
1557+
1558+
/// #736: without a `gems.rb`, bundler loads `Gemfile` + `Gemfile.lock`; a
1559+
/// stray `gems.locked` is not read.
1560+
#[tokio::test]
1561+
async fn gem_inventory_ignores_gems_locked_without_gems_rb() {
1562+
let tmp = tempfile::tempdir().unwrap();
1563+
let root = tmp.path();
1564+
write(
1565+
root,
1566+
"Gemfile.lock",
1567+
&rack_lock("https://rubygems.org/", "2.0.0"),
1568+
)
1569+
.await;
1570+
write(
1571+
root,
1572+
"gems.locked",
1573+
&rack_lock("https://rubygems.org/", "2.2.8"),
1574+
)
1575+
.await;
1576+
assert_eq!(
1577+
gem_purls(&inventory_project(root).await),
1578+
vec!["pkg:gem/rack@2.0.0"]
1579+
);
1580+
}
1581+
1582+
/// #736: the in-memory view (the hosted engine's) picks the same lock: a
1583+
/// `gems.rb` selects `gems.locked`, and its own `.bundle/config` can
1584+
/// select `Gemfile.lock` instead.
1585+
#[tokio::test]
1586+
async fn gem_inventory_memory_view_reads_the_lock_bundler_loads() {
1587+
let mut project = MemoryProject::new();
1588+
project.insert_text("gems.rb", "gem \"rack\"\n");
1589+
project.insert_text("gems.locked", rack_lock("https://rubygems.org/", "2.2.8"));
1590+
project.insert_text("Gemfile.lock", rack_lock("https://rubygems.org/", "2.0.0"));
1591+
let (entries, _) = inventory_project_diagnosed_in(&ProjectView::Memory(&project)).await;
1592+
assert_eq!(gem_purls(&entries), vec!["pkg:gem/rack@2.2.8"]);
1593+
1594+
project.insert_text("Gemfile", "gem \"rack\"\n");
1595+
project.insert_text(".bundle/config", "---\nBUNDLE_GEMFILE: \"Gemfile\"\n");
1596+
let (entries, _) = inventory_project_diagnosed_in(&ProjectView::Memory(&project)).await;
1597+
assert_eq!(gem_purls(&entries), vec!["pkg:gem/rack@2.0.0"]);
1598+
}
1599+
1600+
/// #736: ledger recovery's GEM remote set comes from the lock bundler
1601+
/// loads too, never from an ignored twin's sources.
1602+
#[tokio::test]
1603+
async fn gem_remotes_reads_the_lock_bundler_loads() {
1604+
let tmp = tempfile::tempdir().unwrap();
1605+
let root = tmp.path();
1606+
write(root, "gems.rb", "gem \"rack\"\n").await;
1607+
write(
1608+
root,
1609+
"gems.locked",
1610+
&rack_lock("https://gems.example.com/", "2.2.8"),
1611+
)
1612+
.await;
1613+
write(
1614+
root,
1615+
"Gemfile.lock",
1616+
&rack_lock("https://rubygems.org/", "2.0.0"),
1617+
)
1618+
.await;
1619+
assert_eq!(
1620+
gem_remotes(root).await,
1621+
vec!["https://gems.example.com".to_string()]
1622+
);
1623+
}
1624+
14631625
#[tokio::test]
14641626
async fn inventories_script_and_pylock_files_without_installed_packages() {
14651627
let tmp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)