Skip to content

Commit 0d302dc

Browse files
Fix gem crawler missing Bundler 4 standalone bundle (#796) (#797)
* Start fix for #796 Assisted-by: Claude Code:claude-opus-5-5 * Find Bundler 4 standalone installs in ./bundle `bundle install --standalone` puts gems in ./bundle and the app loads them through bundle/bundler/setup.rb. Bundler 2 also recorded the path in .bundle/config, but Bundler 4 writes no config at all, so the gem crawler never looked in ./bundle. Agent apply then patched an ambient copy of the same gem (or said it was not installed), VEX attested not_affected while the app ran the unpatched copy, and the hosted stale-install warning stayed silent. Probe ./bundle as an install root when bundle/bundler/setup.rb is present, in the slot Bundler 2's recorded path used to take. Fixes #796. Assisted-by: Claude Code:claude-opus-5-5 * Document the standalone bundle install root List the Bundler 4 standalone ./bundle tree in the CLI contract's gem install-root order, so the documented roots match what the crawler probes. Assisted-by: Claude Code:claude-opus-5-5 * Port #851: fix vex alias tests after #605 main is red since #605: two commands::vex_consumed tests assumed the name-keyed resolver never returns npm-aliased copies, but #605 taught it to probe bundled store trees. This ports the tests-only fix from #851 so this PR's CI goes green; it no-ops once main carries #851. Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 99f61d2 commit 0d302dc

4 files changed

Lines changed: 301 additions & 6 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,8 @@ the model is **not uniform** today:
487487
crawler probes the project's Bundler install roots in **bundler's own precedence order** — the app
488488
config file's `BUNDLE_PATH:` (`$BUNDLE_APP_CONFIG/config`, else `<cwd>/.bundle/config` — what
489489
`bundle config set --local path` records), then the **`BUNDLE_PATH` environment variable**, then the
490+
**standalone `<cwd>/bundle`** tree when it holds `bundle/bundler/setup.rb` (what `bundle install
491+
--standalone` writes and the app loads; bundler 4 records no config for it), then the
490492
default `<cwd>/vendor/bundle` — each in both store layouts bundler produces (scoped
491493
`<root>/<engine>/<abi>/gems/` and flat `<root>/gems/`). The env variable is the user's own machine
492494
state, so it is honored verbatim (it may point outside `--cwd`; a leading `~` expands against home);
@@ -496,7 +498,7 @@ the model is **not uniform** today:
496498
`gem_bundle_config_path_ignored` entry in the run-level `warnings[]` of `scan`/`apply` `--json`
497499
envelopes (detail names the config value and the env-`BUNDLE_PATH` remedy), and one stderr
498500
`Warning: …` line on the human path, gated on `!--silent`
499-
(`--silent` = errors only). Explicit env/config roots only count when `--cwd` holds a Bundler
501+
(`--silent` = errors only). Explicit env/config/standalone roots only count when `--cwd` holds a Bundler
500502
manifest/lockfile. When the default `vendor/bundle` root holds no store, the gem homes `gem env`
501503
reports are appended (default gems like rexml/json only ever live there). When several roots hold
502504
**coexisting physical copies of one `gem@version`** (bundler-2's scoped store beside bundler-1's

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

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,8 @@ async fn gem_hosted_redirect_over_stale_install_warns_loudly() {
327327
);
328328
assert_eq!(code, 0, "human re-scan must succeed:\n{stderr}");
329329
assert!(
330-
stderr.contains("Warning: ") && stderr.contains("was switched to its hosted patch, but a stale"),
330+
stderr.contains("Warning: ")
331+
&& stderr.contains("was switched to its hosted patch, but a stale"),
331332
"human mode must print the stale-install warning on stderr:\n{stderr}"
332333
);
333334
assert!(
@@ -540,6 +541,82 @@ async fn gem_hosted_stale_purl_is_not_vex_attested_in_the_same_run() {
540541
assert_eq!(env["status"], "error", "envelope: {env}");
541542
}
542543

544+
/// #796: Bundler 4's `bundle install --standalone` installs into
545+
/// `<proj>/bundle/ruby/<abi>/` and writes `bundle/bundler/setup.rb`, but no
546+
/// `.bundle/config` (bundler 4 no longer remembers CLI flags). That tree is
547+
/// what the app loads, so a stale UNPATCHED copy there must trip the guard
548+
/// with the project-local remedy, and the same run's `--vex` must not
549+
/// attest the purl.
550+
#[tokio::test(flavor = "multi_thread")]
551+
async fn gem_hosted_stale_bundler4_standalone_install_warns_and_is_not_attested() {
552+
let server = MockServer::start().await;
553+
mount_api(&server, None).await;
554+
let tmp = tempfile::tempdir().unwrap();
555+
let proj = tmp.path().join("proj");
556+
std::fs::create_dir_all(&proj).unwrap();
557+
write_manifest_pair(&proj);
558+
let home = proj.join("bundle").join("ruby").join("3.3.0");
559+
let gem_dir = home.join("gems").join(format!("{DEP}-{DEP_VERSION}"));
560+
std::fs::create_dir_all(gem_dir.join("lib")).unwrap();
561+
std::fs::write(gem_dir.join("lib").join("stale_probe_gem.rb"), UPSTREAM_LIB).unwrap();
562+
std::fs::create_dir_all(proj.join("bundle").join("bundler")).unwrap();
563+
std::fs::write(
564+
proj.join("bundle").join("bundler").join("setup.rb"),
565+
"require 'rbconfig'\n",
566+
)
567+
.unwrap();
568+
assert!(!proj.join(".bundle").exists(), "bundler 4 writes no config");
569+
570+
let vex_path = proj.join("out.vex.json");
571+
let (code, stdout, stderr) = common::run_with_env(
572+
&proj,
573+
&[
574+
"scan",
575+
"--mode",
576+
"hosted",
577+
"--json",
578+
"--yes",
579+
"--cwd",
580+
proj.to_str().unwrap(),
581+
"--api-url",
582+
&server.uri(),
583+
"--org",
584+
ORG,
585+
"--api-token",
586+
"fake",
587+
"--vex",
588+
vex_path.to_str().unwrap(),
589+
"--vex-product",
590+
"pkg:gem/app@1.0.0",
591+
],
592+
&[],
593+
);
594+
let env = common::parse_json_envelope(&stdout);
595+
assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}");
596+
let details = stale_warnings(&env);
597+
assert_eq!(
598+
details.len(),
599+
1,
600+
"the stale standalone copy must trip the guard: {env}\nstderr:\n{stderr}"
601+
);
602+
assert!(
603+
details[0].contains(&gem_dir.display().to_string())
604+
&& !details[0].contains("shared gem home"),
605+
"the warning must name the standalone copy as project-local: {}",
606+
details[0]
607+
);
608+
if let Ok(doc) = std::fs::read_to_string(&vex_path) {
609+
assert!(
610+
!doc.contains(PURL),
611+
"a stale standalone copy must never be attested:\n{doc}"
612+
);
613+
}
614+
assert_ne!(
615+
code, 0,
616+
"an all-stale --vex run must fail, not attest.\nstdout:\n{stdout}\nstderr:\n{stderr}"
617+
);
618+
}
619+
543620
/// #483: bundler's cache dir is the `cache_path` setting — `bundle config
544621
/// set --local cache_path vendor/gems` (a committed `.bundle/config`) or a
545622
/// `BUNDLE_CACHE_PATH` export. A fresh checkout whose committed archive at

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

Lines changed: 156 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
55
use super::types::{CrawledPackage, CrawlerOptions};
66
use crate::patch::path_safety;
77
use crate::utils::fs::{
8-
entry_is_dir, home_dir, is_dir, list_dir_entries, normalize_lexically, run_blocking,
8+
entry_is_dir, home_dir, is_dir, is_file, list_dir_entries, normalize_lexically, run_blocking,
99
};
1010
use crate::utils::process::{CommandRunner, SystemCommandRunner};
1111

@@ -314,11 +314,18 @@ impl RubyCrawler {
314314
/// value resolves against the project root, matching
315315
/// `Bundler.bundle_path`; a leading `~` expands against home).
316316
/// Trusted as-is: it is the user's own environment.
317-
/// 3. the `BUNDLE_PATH:` entry of the global config file
317+
/// 3. `<cwd>/bundle` when it holds `bundler/setup.rb` — the tree
318+
/// `bundle install --standalone` writes and the app loads through
319+
/// that script. Bundler 2 also recorded it as `BUNDLE_PATH:
320+
/// "bundle"` (root 1), but bundler 4 no longer remembers CLI flags
321+
/// and writes no config at all, so the marker is the only trace
322+
/// (#796). It counts as an explicit root, like the recorded path it
323+
/// replaces: the `gem env` fallback stays on for the default gems.
324+
/// 4. the `BUNDLE_PATH:` entry of the global config file
318325
/// ([`bundler_global_config_file`], what `bundle config set --global
319326
/// path <dir>` records) — resolved like the env var, and trusted
320327
/// like it: it is the user's own machine state, not project input.
321-
/// 4. `<cwd>/vendor/bundle` — the default deployment/`--path` location.
328+
/// 5. `<cwd>/vendor/bundle` — the default deployment/`--path` location.
322329
///
323330
/// The explicit roots can point anywhere (a machine-wide `BUNDLE_PATH`
324331
/// export must not pull another project's gem store into a non-Ruby
@@ -359,6 +366,10 @@ impl RubyCrawler {
359366
if let Some(v) = bundle_path_env.filter(|v| !v.is_empty()) {
360367
roots.push(resolve_bundle_path(cwd, Path::new(v), home));
361368
}
369+
let standalone_root = cwd.join("bundle");
370+
if is_file(&standalone_root.join("bundler").join("setup.rb")).await {
371+
roots.push(normalize_lexically(&standalone_root).unwrap_or(standalone_root));
372+
}
362373
// The global config's path (`bundle config set --global path`)
363374
// is the user's own machine state, so it is trusted like the
364375
// env var: no containment guard. Bundler's `Settings#path`
@@ -851,7 +862,8 @@ fn verify_gem_at_path_sync(path: &Path) -> bool {
851862
/// JSON envelope is the CLI's job, where `--silent`/`--json` gating lives.
852863
pub struct BundleStoreDiscovery {
853864
/// The discovered installed-gem `gems/` stores, in root-precedence
854-
/// order (local config > env > default `vendor/bundle`). Copies found
865+
/// order (local config > env > standalone `bundle/` > default
866+
/// `vendor/bundle`). Copies found
855867
/// under these are the PRIMARY class in apply's multi-copy fan-out;
856868
/// paths outside them are `gem env` fallback-home copies.
857869
pub stores: Vec<PathBuf>,
@@ -2075,6 +2087,146 @@ mod tests {
20752087
);
20762088
}
20772089

2090+
// ── `bundle install --standalone` root (#796) ─────
2091+
2092+
/// Lay down what `bundle install --standalone` leaves in `<cwd>/bundle`:
2093+
/// the scoped `ruby/<abi>/gems/<leaf>` store plus the
2094+
/// `bundler/setup.rb` load-path script the app requires. Returns the
2095+
/// `gems/` store dir.
2096+
async fn stage_standalone_bundle(cwd: &Path, leaf: &str) -> PathBuf {
2097+
let root = cwd.join("bundle");
2098+
let gems = root.join("ruby").join("3.3.0").join("gems");
2099+
tokio::fs::create_dir_all(gems.join(leaf).join("lib"))
2100+
.await
2101+
.unwrap();
2102+
tokio::fs::create_dir_all(root.join("bundler"))
2103+
.await
2104+
.unwrap();
2105+
tokio::fs::write(
2106+
root.join("bundler").join("setup.rb"),
2107+
"require 'rbconfig'\n$:.unshift File.expand_path(\"#{__dir__}/../#{RUBY_ENGINE}/#{Gem.ruby_api_version}/gems/rack-3.2.7/lib\")\n",
2108+
)
2109+
.await
2110+
.unwrap();
2111+
gems
2112+
}
2113+
2114+
/// Bundler 4 no longer remembers CLI flags, so `bundle install
2115+
/// --standalone` writes NO `.bundle/config` — the `./bundle` tree is
2116+
/// marked only by its `bundler/setup.rb`. Discovery must still find
2117+
/// it: it is the tree the app loads (#796).
2118+
#[tokio::test]
2119+
async fn standalone_bundle_root_discovered_without_config() {
2120+
let dir = tempfile::tempdir().unwrap();
2121+
tokio::fs::write(dir.path().join("Gemfile"), b"gem \"rack\"\n")
2122+
.await
2123+
.unwrap();
2124+
let gems = stage_standalone_bundle(dir.path(), "rack-3.2.7").await;
2125+
2126+
let discovery =
2127+
RubyCrawler::discover_bundle_stores_with_env(dir.path(), None, None, None, None).await;
2128+
assert_eq!(
2129+
discovery.stores,
2130+
vec![gems],
2131+
"standalone store must be discovered"
2132+
);
2133+
// Same class as bundler 2's recorded `BUNDLE_PATH: "bundle"`: an
2134+
// explicit project root, which keeps the `gem env` fallback (the
2135+
// default gems live there), not the implicit vendor/bundle one.
2136+
assert!(!discovery.default_root_has_stores);
2137+
}
2138+
2139+
/// Without the `bundler/setup.rb` marker a `bundle/` dir is just a
2140+
/// project directory (a script folder, a frontend bundle output) and
2141+
/// must not be crawled — or patched — as a gem store.
2142+
#[tokio::test]
2143+
async fn bundle_dir_without_standalone_marker_is_not_a_store() {
2144+
let dir = tempfile::tempdir().unwrap();
2145+
tokio::fs::write(dir.path().join("Gemfile"), b"gem \"rack\"\n")
2146+
.await
2147+
.unwrap();
2148+
let gems = stage_standalone_bundle(dir.path(), "rack-3.2.7").await;
2149+
tokio::fs::remove_file(dir.path().join("bundle").join("bundler").join("setup.rb"))
2150+
.await
2151+
.unwrap();
2152+
assert!(gems.is_dir());
2153+
2154+
let paths = RubyCrawler::get_vendor_bundle_paths_with_env(dir.path(), None, None).await;
2155+
assert!(paths.is_empty(), "no marker, no store; got {paths:?}");
2156+
}
2157+
2158+
/// The standalone probe sits behind the same "looks like a Ruby
2159+
/// project" gate as the other explicit roots.
2160+
#[tokio::test]
2161+
async fn standalone_bundle_root_ignored_without_manifest() {
2162+
let dir = tempfile::tempdir().unwrap();
2163+
stage_standalone_bundle(dir.path(), "rack-3.2.7").await;
2164+
2165+
let paths = RubyCrawler::get_vendor_bundle_paths_with_env(dir.path(), None, None).await;
2166+
assert!(
2167+
paths.is_empty(),
2168+
"no Bundler manifest, no store; got {paths:?}"
2169+
);
2170+
}
2171+
2172+
/// Bundler 2 records `BUNDLE_PATH: "bundle"` for a standalone install,
2173+
/// so the config root and the standalone probe name the same tree: it
2174+
/// must be scanned (and patched) once.
2175+
#[tokio::test]
2176+
async fn standalone_bundle_root_dedups_with_bundler2_config_path() {
2177+
let dir = tempfile::tempdir().unwrap();
2178+
tokio::fs::write(dir.path().join("Gemfile"), b"gem \"rack\"\n")
2179+
.await
2180+
.unwrap();
2181+
let gems = stage_standalone_bundle(dir.path(), "rack-3.2.7").await;
2182+
tokio::fs::create_dir_all(dir.path().join(".bundle"))
2183+
.await
2184+
.unwrap();
2185+
tokio::fs::write(
2186+
dir.path().join(".bundle").join("config"),
2187+
"---\nBUNDLE_PATH: \"bundle\"\n",
2188+
)
2189+
.await
2190+
.unwrap();
2191+
2192+
let paths = RubyCrawler::get_vendor_bundle_paths_with_env(dir.path(), None, None).await;
2193+
assert_eq!(paths, vec![gems]);
2194+
}
2195+
2196+
/// The standalone tree ranks after the explicit config/env roots and
2197+
/// before the implicit `vendor/bundle` default.
2198+
#[tokio::test]
2199+
async fn standalone_bundle_root_probes_between_env_and_default() {
2200+
let dir = tempfile::tempdir().unwrap();
2201+
tokio::fs::write(dir.path().join("Gemfile"), b"gem \"rack\"\n")
2202+
.await
2203+
.unwrap();
2204+
let standalone = stage_standalone_bundle(dir.path(), "rack-3.2.7").await;
2205+
let env_root = dir.path().join("envstore");
2206+
let env_gems = env_root.join("ruby").join("3.3.0").join("gems");
2207+
tokio::fs::create_dir_all(env_gems.join("rack-3.2.7").join("lib"))
2208+
.await
2209+
.unwrap();
2210+
let default_gems = dir
2211+
.path()
2212+
.join("vendor")
2213+
.join("bundle")
2214+
.join("ruby")
2215+
.join("3.3.0")
2216+
.join("gems");
2217+
tokio::fs::create_dir_all(default_gems.join("rack-3.2.7").join("lib"))
2218+
.await
2219+
.unwrap();
2220+
2221+
let paths = RubyCrawler::get_vendor_bundle_paths_with_env(
2222+
dir.path(),
2223+
Some(env_root.as_os_str()),
2224+
None,
2225+
)
2226+
.await;
2227+
assert_eq!(paths, vec![env_gems, standalone, default_gems]);
2228+
}
2229+
20782230
// ── config-sourced root containment (untrusted .bundle/config) ─
20792231

20802232
/// SECURITY: an ABSOLUTE `BUNDLE_PATH` in the (typically committed,

‎crates/socket-patch-core/tests/crawler_ruby_e2e.rs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,70 @@ async fn get_gem_paths_env_root_still_includes_gempath_homes() {
660660
);
661661
}
662662

663+
/// #796: a Bundler 4 `bundle install --standalone` project has NO
664+
/// `.bundle/config` — only `bundle/bundler/setup.rb` plus the
665+
/// `bundle/ruby/<abi>/gems/` store the app loads. With the same
666+
/// `name-version` also installed in an ambient gem home, the standalone
667+
/// copy must be discovered, and ranked ahead of the `gem env` homes, so
668+
/// apply patches the copy the app actually loads (and VEX judges it)
669+
/// instead of patching only the ambient copy.
670+
#[cfg(unix)]
671+
#[tokio::test]
672+
#[serial]
673+
async fn get_gem_paths_finds_bundler4_standalone_tree_before_gem_homes() {
674+
let tmp = tempfile::tempdir().unwrap();
675+
tokio::fs::write(
676+
tmp.path().join("Gemfile"),
677+
b"source \"https://rubygems.org\"\ngem \"rack\", \"~> 3.1\"\n",
678+
)
679+
.await
680+
.unwrap();
681+
let standalone = tmp.path().join("bundle");
682+
let standalone_gems = standalone.join("ruby").join("3.3.0").join("gems");
683+
let standalone_copy = stage_gem(&standalone_gems, "rack", "3.2.7").await;
684+
tokio::fs::create_dir_all(standalone.join("bundler"))
685+
.await
686+
.unwrap();
687+
tokio::fs::write(
688+
standalone.join("bundler").join("setup.rb"),
689+
"require 'rbconfig'\n",
690+
)
691+
.await
692+
.unwrap();
693+
694+
// The ambient copy of the SAME version in the gem home `gem env` reports.
695+
let home = tempfile::tempdir().unwrap();
696+
let home_gems = home.path().join("gems");
697+
let ambient_copy = stage_gem(&home_gems, "rack", "3.2.7").await;
698+
let bin = tempfile::tempdir().unwrap();
699+
install_fake_gem(bin.path(), home.path());
700+
701+
let crawler = RubyCrawler;
702+
let options = options_at(tmp.path());
703+
let paths = with_path(bin.path(), || async {
704+
crawler
705+
.get_gem_paths_with_env(&options, None, None, None, None)
706+
.await
707+
})
708+
.await
709+
.unwrap();
710+
assert_eq!(
711+
paths,
712+
vec![standalone_gems.clone(), home_gems.clone()],
713+
"standalone store first, then the gem-env home; got {paths:?}"
714+
);
715+
716+
// Every physical copy the apply fan-out iterates includes the
717+
// standalone one, found under the PRIMARY store.
718+
let purls = ["pkg:gem/rack@3.2.7".to_string()];
719+
let mut found = Vec::new();
720+
for gems_dir in &paths {
721+
let hits = crawler.find_each_by_purl(gems_dir, &purls).await;
722+
found.extend(hits.into_iter().flatten().map(|p| p.path));
723+
}
724+
assert_eq!(found, vec![standalone_copy, ambient_copy]);
725+
}
726+
663727
/// Bundler stops before the global path when the environment supplies
664728
/// either path flag, even when the flag is false or an empty string.
665729
#[tokio::test]

0 commit comments

Comments
 (0)