Skip to content

fix(gem): crawl bundle-path roots in bundler precedence order, patch every coexisting copy, contain config-sourced roots - #222

Merged
Mikola Lysenko (mikolalysenko) merged 2 commits into
mainfrom
fix/gem-crawler-root-precedence
Aug 20, 2026
Merged

fix(gem): crawl bundle-path roots in bundler precedence order, patch every coexisting copy, contain config-sourced roots#222
Mikola Lysenko (mikolalysenko) merged 2 commits into
mainfrom
fix/gem-crawler-root-precedence

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Follow-up fix for the verified post-merge audit findings on #218 (gem crawler flat-BUNDLE_PATH discovery, squash 3e51259). Mirrors the multi-copy machinery #216 (squash 0433bcb) built for npm rather than inventing a parallel mechanism.

Audit findings and fixes

  1. MULTI-COPY silent false "applied" (most severe). When the same gem@version exists in two discovered stores under one root — scoped vendor/bundle/<engine>/<abi>/gems beside flat vendor/bundle/gems, a real coexist state fix(gem): discover flat BUNDLE_PATH gem layouts — bundler-1 env-var installs were invisible to scan/get/apply #218's tests create — first-wins merging resolved the purl to ONE path: apply patched that copy and reported success while the bundler major that loads the other store ran pristine (vulnerable) bytes.
    Fix (the fix(apply): patch every on-disk copy of a duplicated package (multi-copy silent partial P0) #216 pattern, reused): find_all_packages_for_purls now routes the release-variant ecosystems through an accumulating merge_variant_copies (built on the same push_path the npm merge uses; base-PURL keyed; store-precedence order preserved), and apply's variant branch fans out per copy for gem — per-copy applied events, summary.applied counts each copy (the signal a second copy exists), a failure on any copy fails the run, and a copy that matches no variant fails loudly instead of hiding behind a sibling's success. Rollback already carried every copy post-fix(apply): patch every on-disk copy of a duplicated package (multi-copy silent partial P0) #216 (merge_qualified accumulates + per-path grouping); the new in-process suite pins both directions.
    Scope decisions: PyPI/Maven keep the one-representative contract — their crawlers resolve one install dir per version, and fanning their per-root aliases out is exactly the already_patched double-patch regression fix(apply): patch every on-disk copy of a duplicated package (multi-copy silent partial P0) #216's second commit reverted for nuget. All collapsing consumers (vendor/vex/setup/get/repair vendor) keep the first (highest-precedence) copy. On the audit's scan clause: scan --sync patches every copy (it runs the real nested apply::run), and scan's inventory stays purl-level — deliberately byte-identical to the npm precedent, whose crawl_all keeps the same purl dedup (check_package); emitting duplicate purl rows would diverge from npm and inflate scannedPackages counts.

  2. PRECEDENCE. Root probe order was vendor/bundle → env → .bundle/config, inverting bundler's real precedence. Roots are now probed local config → env → default vendor/bundle (Bundler::Settings order), so wherever one representative is still chosen, it is the copy bundler actually loads. Pinned by bundle_roots_probe_in_bundler_precedence_order (RED on base).

  3. REGRESSION vs pre-fix(gem): discover flat BUNDLE_PATH gem layouts — bundler-1 env-var installs were invisible to scan/get/apply #218 (gem-env fallback suppressed). The new env/config roots tripped the pre-existing early-return in get_gem_paths, so an env-BUNDLE_PATH project lost the gem env fallback that surfaces DEFAULT/system gem homes (default gems like rexml/json never live in a bundle path). Restored: only the historic project-local vendor/bundle probe keeps its early-return; env/config-rooted stores get the gem-env homes appended, deduped. New e2e get_gem_paths_env_root_still_includes_gempath_homes extends the "local includes every gempath home" pin to the env-root case.

  4. SECURITY: config-sourced roots contained-or-skipped. A repo-committed .bundle/config BUNDLE_PATH is attacker-authored input that became a scan/apply WRITE-target root with no guard (absolute values verbatim, .. unnormalized — a malicious clone could direct apply into /usr/local or a sibling checkout). Policy now: after ~ expansion and lexical normalization, a CONFIG-sourced root must be contained within the project root, else it is skipped with a loud stderr warning naming the config value (gem_bundle_config_path_ignored — the crawler has no structured warning channel, so the established stderr path). Windows rooted forms (\evil, C:evil) take the strict branch — Path::join would otherwise substitute them into the base. ENV-sourced BUNDLE_PATH stays trusted (the user's own environment) but is ..-normalized for dedup. normalize_lexically is hoisted from the composer crawler's install-path guard into utils::fs and shared (same containment posture as the setup/gem plugin-index cleanup). Tests: absolute-outside skipped, ../sibling skipped, contained relative (incl. .//.. detours) accepted, plus a pure containment contract.

  5. ~ expansion. resolve_bundle_path treated ~/store as relative (cwd.join); bundler File.expand_paths it against $HOME. A leading bare-~ component now expands against home, injected through the _with_env seams so tests stay hermetic (~user is left as before — it needs bundler's passwd lookup).

  6. BUNDLE_PATH__SYSTEM. BUNDLE_PATH__SYSTEM: "true" makes bundler ignore the recorded path entirely; the config entry now parses as unset (exact-"true" coercion, either key order), and finding 3's fallback surfaces the system gem homes.

  7. Test hermeticity. The six crawler_ruby_e2e.rs tests that laid a Gemfile and asserted exact equality on get_gem_paths while reading ambient BUNDLE_PATH/BUNDLE_APP_CONFIG now route through the new public get_gem_paths_with_env seam — machines with bundler configured no longer go red. The new in-process suite additionally scrubs BUNDLE_* from the child env.

  8. CLI_CONTRACT.md. The stale "gem — the crawler inspects only the project rooted at --cwd" claim replaced with the truthful model: env BUNDLE_PATH honored (user-controlled, may point out of tree), config-sourced paths contained-or-skipped per finding 4, bundler-precedence root order, gem-env fallback semantics, and the multi-copy apply/rollback behavior.

TDD evidence (red → green)

All three headline tests failed on base with the exact defect (flat copy byte-for-byte VULNERABLE while apply reported success / one path carried):

  • ecosystem_dispatch::find_all_packages_for_purls_carries_every_gem_store_copy (dispatch carries both copies, scoped first; collapsing wrapper keeps one)
  • ruby_crawler::bundle_roots_probe_in_bundler_precedence_order (config → env → default)
  • tests/in_process_gem_multicopy.rs (real binary: apply patches BOTH copies, summary.applied == 2, two applied events; rollback restores BOTH)

Live era-image proof (docker run --rm only)

socket-patch-test-gem-b1:gemx (aarch64, ruby 3.1.7, bundler 1.17.3), coexist fixture, oracle loaded via real GEM_HOME rubygems resolution inside the container:

  • Baked (pre-fix) binary in-container: status: success, applied: 1 — flat store loads VULNERABLE, scoped loads FIXED → the silent partial, live.
  • This branch's binary (host apply, container load): applied: 2 — flat FIXED, scoped FIXED.

Gates (verbatim results)

  • touched-files rustfmt --check: clean (cargo fmt repo-wide was deliberately not applied — main carries pre-existing drift in unrelated test files)
  • cargo clippy --workspace --all-features -- -D warnings: clean
  • cargo test -p socket-patch-core --lib: 2413 passed; 0 failed; 4 ignored
  • cargo test -p socket-patch-cli --lib: 430 passed; 0 failed; 1 ignored
  • crawler_ruby_e2e: 25 passed · crawler_composer_e2e: 32 passed · crawlers_empty_paths_e2e: 13 passed
  • e2e_gem (hermetic): 8 passed · in_process_gem_multicopy: 2 · in_process_npm_multicopy: 2 · cli_gem_variant_mismatch_policy: 6 · apply_invariants: 7 · apply_network: 9 · cli_apply_silent: 5 · cli_rollback_silent: 5 · e2e_scan: 5
  • docker (blocking in CI): docker_e2e_gem: 2 · docker_e2e_vendor_gem: 2 · docker_e2e_pypi: 1 · docker_e2e_maven: 5 — all green

Documented follow-ups (deliberately NOT in this PR)

  • Global ~/.bundle/config BUNDLE_PATH (unscoped bundle config set path, the default on bundler >= 2.1) still unsupported.
  • YAMLSerializer backslash-escape unescaping of config values (Windows/UNC paths).
  • The Bundler-manifest gate is cwd-only while bundler itself walks up for the Gemfile.
  • crawlers→setup module edge + duplicated quote-strip parsing between ruby_crawler and setup/gem — suggest a utils::bundler extraction.
  • seen/seen_roots double-dedup readability in the store discovery loop.
  • Test scaffold helper for the repeated store-staging boilerplate in the crawler tests.

🤖 Generated with Claude Code


Note

High Risk
Changes which on-disk gem trees apply writes and adds security containment for attacker-controlled .bundle/config paths; incorrect behavior could leave vulnerable copies unpatched or allow writes outside the project.

Overview
Fixes a silent partial apply for gems: when the same gem@version lives in both Bundler’s scoped and flat gems/ stores, discovery now keeps every physical path (merge_variant_copies in apply resolution), and apply patches each gem copy (mirroring npm multi-copy), with per-copy JSON events and failures if any copy is left unmatched or unpatched. PyPI/Maven and single-path commands still use one representative install dir.

The Ruby crawler now probes install roots in Bundler precedence (.bundle/config → env BUNDLE_PATHvendor/bundle), honors env ~ expansion, treats BUNDLE_PATH__SYSTEM: "true as “no config path,” and only uses the vendor/bundle early-return so env/config roots still get gem env homes for default gems. Committed .bundle/config BUNDLE_PATH values must stay inside the project root after lexical normalization; out-of-tree roots are skipped with a stderr warning—env BUNDLE_PATH remains trusted. Shared normalize_lexically moves to utils::fs (composer + ruby). CLI_CONTRACT.md documents the gem discovery and multi-copy behavior.

Reviewed by Cursor Bugbot for commit af59554. Configure here.


Review round (Bugbot findings — both verified, both fixed in 460b1bf)

Branch note: rebased onto post-omnibus main (a2f07e0, #223) with the ruby_crawler test interleave resolved; the review-round commit sits on that tip.

1. Config-skip warning was stderr-only and ignored --silent (Medium — fixed). gem_bundle_config_path_ignored was a bare eprintln! inside discover_bundle_stores_with_env — invisible to every --json consumer and printed under --silent, violating the repo-wide warning conventions (#219/#220 + #223's silent fixes). The crawler is now print-free: the refusal is recorded on BundleStoreDiscovery.skipped_config_path, and a shared config_path_ignored_warning(value) builder feeds the CLI channels — scan pushes it onto the same run-level channel as the PnP layout refusals (JSON warnings[] on both the zero-package and ≥1-package envelopes; one stderr line gated on !json && !silent), and apply carries it in its envelope warnings[] plus one gated stderr line. Scoped like the crawl that hit it: local mode, gem in --ecosystems/manifest scope. RED-first pins: tests/in_process_gem_config_warning.rs (scan JSON, apply JSON, silent gate with loud control — all three failed on the pre-fix tip).

2. Fallback-home copies made shared-home failures fail the run (High — fixed, with one nuance kept deliberate). With an env/config bundle root, get_gem_paths appends the gem env fallback homes and the fan-out patched every copy with per-copy loud-fail — so a gem present in both the bundle store and a shared home (rvm @global, root-owned system dir) failed the whole run on a permission failure or variant mismatch there, even though the copy bundler loads patched fine. Patching the shared copy is not itself wrong (plain apply always patched GEM_HOME when no store existed); the defect was failure semantics crossing store classes. Fix: discovery's store list is exposed (RubyCrawler::discover_bundle_stores, fs probes only) and apply's gem fan-out classes each copy — bundle-path store copies stay primary (loud-fail, unchanged); gem-env fallback-home copies become best-effort once at least one store copy applied: a variant mismatch or write failure there is a per-copy non-fatal skipped event (errorCode: gem_fallback_home_skipped, detail names the path and reason; gated stderr twin), never a run failure. Parity edge kept: with no bundle-store copy (the historic fallback-only layout, and every --global run) the home copy IS primary and keeps loud-fail exactly as pre-#218 apply. RED-first pins: tests/in_process_gem_fallback_home.rs — mismatched-home nonfatal (was exit 1), strict-refusal-on-home nonfatal (was Failed event), fallback-only loud parity, both-copies-patched. CLI_CONTRACT.md documents the copy classes and warning channels.

Review-round gates: touched-files rustfmt clean; cargo clippy --workspace --all-features -- -D warnings clean; core --lib 2535, cli --lib 436; crawlers::ruby 50, crawler_ruby_e2e 25; gem+npm multicopy 2+2; apply_network 11, apply_invariants 4, cli_gem_variant_mismatch_policy 6, cli_apply_silent 2, e2e_gem hermetic 8, e2e_scan, cli_scan_silent, docker_e2e_gem — all green.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Gem apply patches global gem homes
    • Changed early-return condition to prevent adding global gem homes when any bundle stores exist, avoiding unwanted patching of machine-wide gem installations.

Create PR

Or push these changes by commenting:

@cursor push 81f3fac8b4
Preview (81f3fac8b4)
diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs
--- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs
+++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs
@@ -77,16 +77,16 @@
         )
         .await;
 
-        // Historic early-return, kept ONLY for the implicit project-local
-        // `vendor/bundle` probe: a deployment-style install is the
-        // project's one gem source, so the ambient gem homes don't apply.
-        // Stores found via an env/config root do NOT suppress the fallback
-        // below: default gems (rexml, json, …) never live in a bundle path
-        // — they ship with ruby in the DEFAULT/system gem homes — so an
-        // env-`BUNDLE_PATH` project still needs the `gem env` homes to see
-        // them (the explicit-roots feature briefly suppressed that
-        // pre-existing fallback).
-        if discovery.default_root_has_stores {
+        // When ANY bundle stores are found (vendor/bundle, env, or config),
+        // return ONLY those stores without appending `gem env` homes. A
+        // deployment-style install or an env/config-rooted store is the
+        // project's gem source, so the ambient gem homes must not become
+        // apply patch targets — patching a machine-wide global gem home
+        // (which `bundle exec` doesn't load) risks permission failures and
+        // the nuget-style global-cache hazard this otherwise avoids.
+        // Default gems (rexml, json, …) never in a bundle path also aren't
+        // in the manifest, so skipping the fallback here is safe.
+        if !discovery.stores.is_empty() {
             return Ok(discovery.stores);
         }

You can send follow-ups to the cloud agent here.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit af59554. Configure here.

Comment thread crates/socket-patch-core/src/crawlers/ruby_crawler.rs Outdated
Comment thread crates/socket-patch-core/src/crawlers/ruby_crawler.rs
…every coexisting copy, contain config-sourced roots

Post-merge audit follow-up on #218 (gem crawler flat-BUNDLE_PATH
discovery), mirroring the #216 npm multi-copy precedent (0433bcb).

1. MULTI-COPY (most severe): bundler's scoped `<engine>/<abi>/gems`
   and flat `gems/` stores coexist under one root, each holding a REAL
   physical copy of the same gem@version — exactly the state #218's
   tests create. First-wins merging resolved the purl to ONE path, so
   apply patched one store and reported success while the other
   bundler loaded pristine (vulnerable) bytes. Fix mirrors #216:
   `find_all_packages_for_purls` now routes the release-variant
   ecosystems through an accumulating `merge_variant_copies` (reusing
   `push_path`, base-PURL keyed, precedence order kept) and apply's
   variant branch fans out per copy for gem — per-copy Applied events,
   `summary.applied` counts each copy, a copy matching no variant
   fails loudly. Rollback already carried every copy via
   `merge_qualified` and its per-path grouping; the new in-process
   suite pins both directions. PyPI/Maven deliberately keep the
   one-representative contract (the nuget `already_patched`
   double-patch regression from #216's second commit), as do all
   collapsing consumers (vendor/vex/setup/get/repair-vendor).
   scan --sync patches every copy too (it runs the real nested apply);
   scan's inventory stays purl-level, byte-identical to npm's
   crawl_all purl-dedup precedent.

2. PRECEDENCE: roots probed local-config > env > default vendor/bundle
   (bundler's real precedence; the old order was inverted), so
   first-representative consumers pick the copy bundler actually loads.

3. REGRESSION vs pre-#218: env/config roots no longer trip the
   `gem env` fallback early-return — only the historic project-local
   vendor/bundle probe keeps it. Default gems (rexml/json) live only
   in the DEFAULT/system gem homes, so an env-BUNDLE_PATH project gets
   those homes appended (deduped) again.

4. SECURITY: a config-sourced BUNDLE_PATH (committed .bundle/config =
   attacker-authored input, and a scan/apply WRITE-target root) must
   now, after ~ expansion and lexical normalization, stay contained in
   the project root — otherwise the root is skipped with a
   `gem_bundle_config_path_ignored` stderr warning naming the value.
   Windows rooted forms (`\evil`, `C:evil`) take the strict branch.
   Env-sourced BUNDLE_PATH stays trusted (user's own environment) but
   is normalized for dedup. `normalize_lexically` is hoisted from the
   composer crawler into utils::fs and shared.

5. `~` EXPANSION: a leading `~`/`~/...` in BUNDLE_PATH expands against
   home (bundler File.expand_path), env-injectable via the _with_env
   seams for hermetic tests.

6. BUNDLE_PATH__SYSTEM: `"true"` makes bundler ignore the recorded
   path — the config entry now parses as unset and the fallback finds
   the system gem homes.

7. TEST HERMETICITY: the six crawler_ruby_e2e tests that read ambient
   BUNDLE_PATH/BUNDLE_APP_CONFIG now route through the new
   `get_gem_paths_with_env` seam; a new e2e pins env-root + gem-env
   fallback coexistence (finding 3).

8. CLI_CONTRACT.md: the stale "gem inspects only <cwd>/vendor/bundle"
   claim replaced with the real root model, the containment policy,
   and the multi-copy behavior.

TDD evidence (red -> green): dispatch-level
`find_all_packages_for_purls_carries_every_gem_store_copy`, crawler
`bundle_roots_probe_in_bundler_precedence_order`, and the new
`in_process_gem_multicopy.rs` (real binary apply/rollback over a
coexisting two-store tree) all failed on base — the flat copy stayed
byte-for-byte VULNERABLE while apply reported success — and pass now.

Live era-image proof (docker run --rm, socket-patch-test-gem-b1:gemx,
bundler 1.17.3): the baked pre-fix binary on a coexist fixture reports
status=success/applied=1 while the flat store's copy — loaded via real
GEM_HOME resolution in the container — still evaluates VULNERABLE; the
fixed binary reports applied=2 and both stores load FIXED.

Gates: touched files rustfmt-clean; cargo clippy --workspace
--all-features -D warnings clean; core --lib 2413 passed; cli --lib
430 passed; crawler_ruby_e2e 25, crawler_composer_e2e 32,
crawlers_empty_paths_e2e 13; e2e_gem hermetic 8; in-process gem+npm
multicopy suites; cli_gem_variant_mismatch_policy 6; docker_e2e_gem,
docker_e2e_vendor_gem, docker_e2e_pypi, docker_e2e_maven all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lass-split fallback-home failure semantics

Review-round fixes for the two verified Bugbot findings on #222.

Finding 1 (Medium): the containment guard's
`gem_bundle_config_path_ignored` was a bare eprintln inside the crawler —
it never reached any --json `warnings[]` and printed under --silent,
violating the repo-wide warning conventions (#219/#220 + the #223
omnibus silent fixes). The crawler is now print-free: the refusal is
RECORDED on `BundleStoreDiscovery.skipped_config_path`, and a shared
`config_path_ignored_warning(value)` builder feeds the CLI channels —
scan pushes it onto the same run-level channel as the PnP layout
refusals (JSON `warnings[]` on both the zero-package and >=1-package
envelopes; one stderr line gated on !json && !silent), and apply carries
it in its Envelope `warnings[]` plus one gated stderr line. Scoped like
the crawl that hit it: local mode, gem in --ecosystems/manifest scope.

Finding 2 (High, with nuance): with an env/config bundle root,
get_gem_paths appends the gem-env fallback homes and the multi-copy
fan-out patched EVERY copy with per-copy loud-fail — so a gem present in
both the bundle store and a shared home (rvm @global, root-owned system
dir) failed the WHOLE run on a permission failure or variant mismatch
THERE, even though the copy bundler loads patched fine. Patching a
shared home's copy is not itself wrong (plain apply always patched
GEM_HOME when no store existed); the defect was failure semantics
crossing store classes. Fix: discovery's store list is exposed
(`RubyCrawler::discover_bundle_stores`, fs-probes only) and apply's gem
fan-out classes each copy — bundle-path store copies stay PRIMARY
(loud-fail, unchanged); gem-env fallback-home copies become BEST-EFFORT
once at least one store copy applied: a variant mismatch or write
failure there is a per-copy non-fatal Skipped event
(`gem_fallback_home_skipped`, detail names the path and reason; gated
stderr twin), never a run failure. Parity edge kept: with NO bundle-store
copy (the historic fallback-only layout, and every --global run) the
home copy IS primary and keeps loud-fail exactly as pre-#218 apply.

TDD evidence (red -> green on the rebased tip): scan/apply --json
missing the warnings[] entry and the --silent leak (3 tests,
in_process_gem_config_warning.rs); mismatched-home-copy exit 1 and
Failed-event-on-strict-refusal (in_process_gem_fallback_home.rs), with
fallback-only loud parity and both-copies-patched pinned green
throughout. CLI_CONTRACT.md documents the copy classes and the warning
channels.

Gates: touched files rustfmt-clean; cargo clippy --workspace
--all-features -D warnings clean; core --lib 2535, cli --lib 436;
crawlers::ruby 50, crawler_ruby_e2e 25; gem+npm multicopy 2+2;
apply_network 11, apply_invariants 4, cli_gem_variant_mismatch_policy 6,
cli_apply_silent 2, e2e_gem hermetic 8 (6 shown +cache selftests),
e2e_scan, cli_scan_silent, docker_e2e_gem — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mikolalysenko
Mikola Lysenko (mikolalysenko) merged commit fb7e518 into main Aug 20, 2026
40 of 42 checks passed
@mikolalysenko
Mikola Lysenko (mikolalysenko) deleted the fix/gem-crawler-root-precedence branch August 20, 2026 14:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants