diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md
index b02fa44fa..2f04aaf4f 100644
--- a/crates/socket-patch-cli/CLI_CONTRACT.md
+++ b/crates/socket-patch-cli/CLI_CONTRACT.md
@@ -167,7 +167,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm
**Hosted sbt (v5.0, additive)**: an sbt build root (`project/build.properties` naming an `sbt.version`, 0.13.18 or later) is wired through ONE generated root file, `socket-patch.sbt` — no user file is edited. It pins every granted Maven patch build-wide (a `ThisBuild` `dependencyOverrides +=` of the Socket-only `-socket.` version plus a `file:` resolver over `.socket/sbt-hosted/maven2/`, moved ahead of the default repositories on sbt 0.13 / 1.x so an unreachable one never blocks it offline), downloads the pinned pom and jar there on the first sbt load (sha256-checked, gitignored by the file itself), and installs a load-time verifier that fails `update` when any project resolves another version or a pinned artifact whose bytes are not pinned. Edits: `redirect_sbt_pin` (added), `redirect_sbt_pin_updated` (an existing row replaced: same GA and base under a new uuid, or the same uuid with new served values; `original` names the previous uuid and version), `redirect_sbt_pin_rechecked` (an existing row re-verified after the build's dependencies changed: its dependency digest is recorded anew, `original`/`new` are `{deps}`). The load-time verifier also fails `update` when a project declares a pinned GA at a version newer than the pin's base (the build-wide override would otherwise force it back down). A new pin is gated on sbt's own resolution records under `target/` (never the machine-wide cache): run-level stops wire nothing, warn once and exit 0 — `redirect_sbt_no_resolution_evidence` (none; run `sbt update` first; always the in-memory engine's answer), `redirect_sbt_resolution_incomplete` (a declared project left no evidence, or the project definitions cannot be read statically), `redirect_sbt_resolution_stale` (a build source is newer than some project's evidence: each project is dated by its own newest record, so a partial `sbt /update` does not vouch for the others). Per-patch refusals (never confirmed): `redirect_sbt_missing_override` (no `maven2` override or no suffixed version), `redirect_sbt_integrity_missing` (jar or pom sha256 missing), `redirect_sbt_unsafe_value` (a value unsafe in a Scala literal, or an index URL not naming the uuid), `redirect_sbt_version_conflict` (some project resolves another version, or a build source declares the GA newer than the patch's base), `redirect_sbt_override_conflict` (two patches for one GA in a run, or another base already pinned), `redirect_sbt_vendored_conflict` (the GA is pinned by `socket-patch-vendor.sbt`, or that file cannot be parsed — then every Maven patch), `redirect_sbt_owned_file_modified` / `redirect_sbt_owned_file_foreign` (`socket-patch.sbt` edited, or not socket-patch's — every Maven patch), `redirect_sbt_owned_file_unreadable` (a whole-run refusal: `socket-patch.sbt` is on disk but cannot be read as UTF-8 text, so writing it would replace it; nothing is written), `redirect_sbt_unsupported_version`, `redirect_sbt_build_root_unknown` (sbt files but no versioned build root — every Maven patch), `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` (a build source reassigns `dependencyOverrides` / `resolvers` with `:=`, `~=` or `--=`), `redirect_sbt_dependency_lock_present` (a `build.sbt.lock`), `redirect_sbt_scala_runtime_unsupported` (`org.scala-lang`), `redirect_sbt_classifier_unsupported`; a GA no library configuration resolves is skipped silently (`redirect_sbt_meta_build_only` when only the meta-build resolves it). Advisories: `redirect_sbt_version_untested` (sbt 2.1+, still wired), `redirect_sbt_override_build_repos` (`sbt.override.build.repos=true`), `redirect_maven_pom_ignored_sbt_build` (a `pom.xml` beside the sbt build, which sbt never reads; the Maven rewriter still edits it for the Maven build). A re-run keeps an existing row and re-checks it. When the build's dependency digest changed since the pin, evidence resolved after the change (fresh, newer than the generated file) re-verifies it and the row's digest is refreshed (`redirect_sbt_pin_rechecked`); the uuid is NOT confirmed on `redirect_sbt_pin_declared_newer` (a build source now declares the GA newer than the pin's base; the row stays, sbt's load-time verifier fails the build, and the remedy is `socket-patch rollback` or declaring the base again), `redirect_sbt_pin_unverifiable` (the digest changed and the evidence predates the change, or the digest cannot be computed: run `sbt update`, then re-run socket-patch), `redirect_sbt_override_shadowed` (the evidence still resolves the base version) or `redirect_sbt_resolved_elsewhere` (the pinned version resolves from outside the pin repository from a file whose sha256 is not the pinned jar's; a copy holding the pinned bytes, such as the Ivy cache a second checkout reads, is fine — at most 64 pinned artifact files of up to 256 MiB are hashed, anything else counts as elsewhere), and also when a build source now reassigns `dependencyOverrides` / `resolvers` or a `build.sbt.lock` appeared (the same `redirect_sbt_overrides_assignment` / `redirect_sbt_resolvers_assignment` / `redirect_sbt_dependency_lock_present` codes; the row stays and sbt's load-time verifier fails the build). For a pure sbt root (no `pom.xml` / Gradle script beside it), maven confirmation is decided only by the sbt rewriter's report; on a mixed root a uuid the sbt rewriter refused is still confirmed by the Maven rewriter's own `pom.xml` pin (the generated sbt files never prove a pin by substring). **Mill and scala-cli** are guidance only: per Maven patch `redirect_mill_manual_snippet` / `redirect_scala_cli_manual_snippet` carry a paste-able snippet (repository + forced suffixed version), nothing is written or confirmed, and a pure Mill / scala-cli root gets no `redirect_maven_no_pom`; there, a Maven patch the server sent without a `maven2` registry override gets `redirect_maven_missing_override` instead of a snippet (with a `pom.xml` beside the Mill / scala-cli files the pom rewriter reports it). `rollback` / `remove` restore `socket-patch.sbt` offline (the rows removed, the file deleted with its last pin; the gitignored downloads are left). Manifest-less VEX reads every strictly parsed pin as a hosted reference but grants it the lockfile basis only when the local evidence shows every recorded version of the GA is the pinned one and every recorded artifact hashes to a pinned sha256 (else `sbt_resolution_unverified`).
-**Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery, plus — read-only — a `.bundle/config` bundle path refused as a write root because it resolves outside the project, which takes the project-local remedy) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** among this run's fetched records (v5.0: hosted mode persists no records, so a purl whose `/patches/view` fetch failed this run is not judged; the warning re-fires on every re-scan whose fetch succeeds, until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `/.gem` when present and not proven to be the patched artifact, since bundler installs from its cache dir in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed cache-dir archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The cache dir is bundler's `cache_path` setting (`Bundler.app_cache`), resolved in `Bundler::Settings` priority: `BUNDLE_CACHE_PATH:` in the bundler app config (`$BUNDLE_APP_CONFIG/config`, else `.bundle/config`) first, then the `BUNDLE_CACHE_PATH` environment variable, then `BUNDLE_CACHE_PATH:` in the global config (`bundle config set --global`: `$BUNDLE_CONFIG`, else `$BUNDLE_USER_CONFIG`, else `$BUNDLE_USER_HOME/config`, else `~/.bundle/config`), else `vendor/cache`; a relative value is read against the project root. The same global tier, below the app config and the environment, applies to `BUNDLE_GEMFILE:` and, for agent-mode install-root discovery, to `BUNDLE_PATH:`. A present local or environment `path`, `path.system`, or `disable_shared_gems` setting (including an empty string or false flag) shadows the global path tier, matching the tested Bundler 2.6/4 behavior; Bundler 1.x's legacy global-path shortcut is not modeled. An empty higher-tier `gemfile` setting also shadows the global value but leaves an existing nonempty `BUNDLE_GEMFILE` environment value in effect, or uses default manifest discovery when there is none. With `BUNDLE_IGNORE_CONFIG` set (any value) bundler reads no config file, so the app and global configs are skipped here too and only the environment and the default count — the same holds for the `BUNDLE_GEMFILE:` app-config setting. The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract.
+**Gem stale-install guard (additive warning — the canonical narrative; other mentions point here)**: the gem hosted rewrite is pure Gemfile/lock text, so a gem ALREADY materialized under the project's bundle paths keeps its upstream bytes — the next `bundle install` prints `Using ` and never refetches, on **every** bundler major (live-verified 2026-08-19 on 1.17.3 / 2.7.2 / 4.0.18: bundler 4's CHECKSUMS verify at download time only, and nothing is downloaded; `bundle install --force`/`--redownload` re-install from the stale cached `.gem` instead of re-fetching — bundler 1 silently, bundler 4 with an exit-37 checksum refusal that still leaves the upstream bytes installed; the **verified** remedy is removing the installed dir + cache `.gem` + `specifications` entry, then `bundle install`). After the rewrite, a hosted run therefore probes the installed-gem discovery paths (the same ruby-crawler discovery `apply` uses, honoring `--global`/`--global-prefix` like scan's own discovery, plus — read-only — a `.bundle/config` bundle path refused as a write root because it resolves outside the project, which takes the project-local remedy; the `gem env` homes count only when Bundler uses system gems, i.e. no deployment store under `vendor/bundle`, and the first settings tier (app config, environment, global config) that sets `path`, `path.system` or `disable_shared_gems` doesn't set a non-empty `path` without `path.system: true` or `disable_shared_gems: false`, since with such a `path` `bundle install` fetches non-default gems into it and never reuses a system copy) for each confirmed gem redirect and judges the materialization against the patch record's `afterHash` file map. Judgment rules: records are found **by uuid** among this run's fetched records (v5.0: hosted mode persists no records, so a purl whose `/patches/view` fetch failed this run is not judged; the warning re-fires on every re-scan whose fetch succeeds, until the stale materialization is gone); a materialization with every file at `afterHash` is already patched and never warns (an agent→hosted migration stays quiet by construction), and when several confirmed variant purls resolve to one installed dir, ANY of them judging it patched keeps it quiet; staleness needs **positive evidence** — at least one record file whose bytes were actually read and hash to neither state's expectation — so missing or unreadable files never produce a warning. Warnings emit `redirect_gem_stale_install` (JSON `redirect.warnings[]` + a code-tagged stderr line) in three flavors: a PROJECT-LOCAL dir (under the project root, compared on absolute paths so the default `--cwd .` counts, or under the project's own refused `.bundle/config` path) gets the verified delete-list remedy (installed dir, cache `.gem`, `specifications` entry — plus the project's committed `/.gem` when present and not proven to be the patched artifact, since bundler installs from its cache dir in preference to fetching); a SHARED gem-env home gets a caveat that the home is shared machine-wide and prefers migrating the project to a local bundle path over deleting shared files; and a committed cache-dir archive whose sha256 differs from the patched artifact's warns standalone even with no installed dir at all (a fresh checkout with a committed stale cache re-materializes the upstream bytes forever). A stale-flagged purl is additionally **excluded from the same run's `--vex` `assume_applied` set** — the envelope must never attest a CVE its own warning says is live; the purl falls back to normal installed-tree verification (a patched install still attests, a stale one is omitted). The cache dir is bundler's `cache_path` setting (`Bundler.app_cache`), resolved in `Bundler::Settings` priority: `BUNDLE_CACHE_PATH:` in the bundler app config (`$BUNDLE_APP_CONFIG/config`, else `.bundle/config`) first, then the `BUNDLE_CACHE_PATH` environment variable, then `BUNDLE_CACHE_PATH:` in the global config (`bundle config set --global`: `$BUNDLE_CONFIG`, else `$BUNDLE_USER_CONFIG`, else `$BUNDLE_USER_HOME/config`, else `~/.bundle/config`), else `vendor/cache`; a relative value is read against the project root. The same global tier, below the app config and the environment, applies to `BUNDLE_GEMFILE:` and, for agent-mode install-root discovery, to `BUNDLE_PATH:`. A present local or environment `path`, `path.system`, or `disable_shared_gems` setting (including an empty string or false flag) shadows the global path tier, matching the tested Bundler 2.6/4 behavior; Bundler 1.x's legacy global-path shortcut is not modeled. An empty higher-tier `gemfile` setting also shadows the global value but leaves an existing nonempty `BUNDLE_GEMFILE` environment value in effect, or uses default manifest discovery when there is none. With `BUNDLE_IGNORE_CONFIG` set (any value) bundler reads no config file, so the app and global configs are skipped here too and only the environment and the default count — the same holds for the `BUNDLE_GEMFILE:` app-config setting. The probe is read-only (nothing is deleted) and skipped on `--dry-run` — deliberately explicit, since nothing was rewritten. Exit code and `status` are unchanged (warning-only, the hosted-refusal posture); a same-run `--vex` may still fail on "nothing to attest" per the embedded-VEX contract.
**Pipenv hosted redirect (`Pipfile.lock`, pipfile-spec 6)**: every category other than `_meta` (`default`, `develop`, and Pipenv 2022+ named categories) that pins the package at the patched version is rewritten to the hosted reference — `{"file" | "path": "#sha256=", "hashes": ["sha256:"]}` with `markers`/`extras`/`index` kept exactly as Pipenv wrote them (present or absent: whether Pipenv records `index` depends on its release, the Pipfile spelling and the locking environment, so only the entry itself knows) and `version` dropped; `_meta` (the Pipfile content hash) and the Pipfile itself are never touched, so `pipenv install --deploy`/`sync`/`verify` keep passing. The reference KEY depends on the installing Pipenv: releases 7–11 only install `path` references, 2018 and later `file` ones (0–6 write pipfile-spec < 6 and are refused). The release is probed once per command with `pipenv --version`, resolved on ABSOLUTE `PATH` entries only (a relative entry would run a `pipenv` planted in the scanned repository; `.bat`/`.cmd` shims are found through `PATHEXT` on Windows), only when a pypi patch actually targets an entry of the lock, and `SOCKET_PIPENV_MAJOR=` pins the answer without spawning anything. An unknown installer selects `file` and warns `redirect_pipenv_installer_unknown` only when the lock was rewritten. **Refusal scope**: a pin/source CONFLICT (another version pinned, a foreign `file`/`path` source, a VCS/editable dependency) refuses the whole dependency atomically across categories as `redirect_pipenv_refused` AND vetoes the sibling Python rewriters (requirements.txt / uv.lock / pyproject) for that patch — the project's Pipenv install could not pick the patch up, so a half-redirected checkout is refused; anything else (no entry for the package, an old pipfile-spec, an unparseable lock, a digest-less patch) is `redirect_pipenv_skipped` and leaves the siblings alone (a stale Pipfile.lock in a uv/Poetry/requirements project must not block them). The veto applies to a LIVE lock only: a `Pipfile.lock` with no `Pipfile` beside it is abandoned, so its conflict refuses that file but never the siblings. Hash enforcement at install time is split by era — the `#sha256=` URL fragment is what Pipenv 2023+ verifies, the `hashes` list what 2018–2022 verify, Pipenv 11 either — so both are load-bearing. **Pipenv stale-install guard**: Pipenv never reinstalls a release that is already present (`pipenv install`, `install --deploy` and `sync` all exit 0 and keep the installed bytes — measured on 11.10.4, 2018.11.26 and 2026.8.0, hosted and vendored), so after the rewrite the run probes the Python crawler's site-packages (VIRTUAL_ENV, `./.venv`, `./venv`, Pipenv's out-of-tree `WORKON_HOME` venv; `--global`/`--global-prefix` honoured) for each confirmed Pipfile.lock redirect with the same rules as the gem guard (records by uuid from this run's fetch, PATCHED = `verify_patch_record` Ok, STALE needs positive evidence, read-only, skipped on `--dry-run`, stale purls excluded from the same-run `--vex` `assume_applied` set) and the Python stale-install guard (`redirect_pypi_stale_install`, see above) names the site-packages dir and the Pipenv-specific verified remedy: `pipenv run pip uninstall -y && pipenv sync` (or `pipenv --rm && pipenv sync`), with the `sync` arguments following the lock, since plain `pipenv sync` installs only `default`: the targeted form re-syncs the categories that pin the package (`--dev` for `develop`, `--categories ""` for a named category) and the `--rm` form re-syncs every non-empty category — NOT `pipenv uninstall`, which rewrites the Pipfile and re-locks the patch away. The vendored backend emits the twin `pypi_pipenv_stale_install` (`skipped` warning event). **Rollback** (v5.0, upstream restore): each hosted entry gets its registry shape back — `"version": "=="`, the entry's own `index` carried back unchanged (refused unless it — and the Pipfile's explicit `index`, if any — names a PyPI source in `_meta.sources`), and every release file's sha256 from PyPI's JSON API (`SOCKET_PYPI_JSON_API`), sorted by filename as Pipenv records them; an entry that pins another version beside the hosted reference is refused with the `git checkout` remedy (see "Hosted unwind coverage"). A Pipfile names no project, so a same-run `--vex` on a Pipenv project needs `--vex-product` (or a git remote) to detect a product purl. **Discovery**: `Pipfile.lock` is part of the lockfile inventory (every category's `==` pins, with the lock's digest set as `Sha256AnyOf` integrity so a lock-only checkout can be vendored by fetching the pure wheel through PyPI's JSON API — only when `_meta.sources` name the public index; a private-index lock stays discovery-only and never reaches pypi.org), and Socket's own hosted / vendored references stay discoverable as the package they replace, so a re-scan of an already-redirected or already-vendored lock-only checkout re-confirms it (`--vex` attests, vendored reports `already_vendored`) instead of finding nothing.
diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs
index 27ab8dc18..af7105fd1 100644
--- a/crates/socket-patch-cli/src/commands/scan/hosted.rs
+++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs
@@ -298,7 +298,8 @@ async fn installed_stale_positive_evidence(
/// exactly like scan's own discovery; layouts the crawler grows into are
/// covered automatically. A `.bundle/config` path the containment guard
/// refuses as a write root is still READ here
-/// (`verification_only_gem_paths`): bundler installs into it.
+/// (`bundler_install_homes`): bundler installs into it. The `gem env`
+/// homes are judged only when bundler uses system gems (#1001).
/// * Records are found BY UUID (the fetch key, stable across purl
/// spellings) among this run's fetched records; v5 keeps no hosted
/// ledger to fall back on, so a uuid whose `/patches/view` fetch failed
@@ -360,6 +361,7 @@ async fn gem_stale_install_warnings(
leaf: String,
patched: bool,
positive: bool,
+ project_local: bool,
}
let crawler = RubyCrawler::new();
let options = CrawlerOptions {
@@ -367,17 +369,14 @@ async fn gem_stale_install_warnings(
global,
global_prefix,
};
- let mut gem_paths = crawler.get_gem_paths(&options).await.unwrap_or_default();
- // A `.bundle/config` path outside the project is refused as a write
- // root, yet bundler installs into and loads from it: read it too, or a
- // stale materialization there never warns (#709). It is this project's
- // own bundle path, so it takes the project-local remedy.
- let config_stores = crawler.verification_only_gem_paths(&options).await;
- for gems_dir in &config_stores {
- if !gem_paths.contains(gems_dir) {
- gem_paths.push(gems_dir.clone());
- }
- }
+ // Only the homes `bundle install` installs into or reuses, each tagged
+ // project-local or shared by the crawler. That covers a `.bundle/config`
+ // path outside the project, which is refused as a write root but which
+ // bundler installs into and loads from (#709). It leaves out the
+ // `gem env` homes when an explicit Bundler `path` means bundler never
+ // reuses a copy there (#1001), and the project-local tag doesn't depend
+ // on how `--cwd` is spelled (#729).
+ let homes = crawler.bundler_install_homes(&options).await;
// Every candidate's installed dir in every gem home, one blocking pass
// (and at most one listing) per home — the per-candidate lookups the
// loop below consumes, in the same (candidate, home) order.
@@ -385,9 +384,12 @@ async fn gem_stale_install_warnings(
.iter()
.map(|(purl, _)| socket_patch_core::utils::purl::strip_purl_qualifiers(purl).to_string())
.collect();
- let mut found_per_home = Vec::with_capacity(gem_paths.len());
- for gems_dir in &gem_paths {
- found_per_home.push(crawler.find_each_by_purl(gems_dir, &stripped).await);
+ let mut found_per_home = Vec::with_capacity(homes.len());
+ for home in &homes {
+ found_per_home.push((
+ crawler.find_each_by_purl(&home.gems_dir, &stripped).await,
+ home.project_local,
+ ));
}
let mut dir_state: std::collections::BTreeMap =
std::collections::BTreeMap::new();
@@ -395,7 +397,7 @@ async fn gem_stale_install_warnings(
// the committed archives `bundle install` installs from (#483).
let app_cache = socket_patch_core::crawlers::ruby_crawler::bundler_app_cache_dir(cwd).await;
for (index, (purl, record)) in candidates.iter().enumerate() {
- for found in &found_per_home {
+ for (found, project_local) in &found_per_home {
let Some(pkg) = &found[index] else {
continue;
};
@@ -412,6 +414,7 @@ async fn gem_stale_install_warnings(
leaf: leaf.to_string(),
patched: false,
positive: false,
+ project_local: *project_local,
});
let judged = judge_installed_record(&pkg.path, record).await;
if judged.patched {
@@ -432,8 +435,7 @@ async fn gem_stale_install_warnings(
if j.patched || !j.positive {
continue;
}
- let project_local =
- dir.starts_with(cwd) || config_stores.iter().any(|store| dir.starts_with(store));
+ let project_local = j.project_local;
let mut folded_cache: Option = None;
if project_local {
let project_cache = app_cache.join(format!("{}.gem", j.leaf));
diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs
index c75039a49..9f945ea56 100644
--- a/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs
+++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs
@@ -1255,3 +1255,233 @@ async fn gem_hosted_stale_install_under_out_of_tree_config_path_is_not_attested(
UPSTREAM_LIB
);
}
+
+/// Lay down a stale UNPATCHED copy in a machine gem home (`/gems/…`,
+/// plus its cache `.gem` and specifications entry) and a fake `gem` on
+/// `` whose `gem env gemdir` answers that home (and whose
+/// `gempath` fails), so the crawler's `gem env` fallback resolves to
+/// exactly this one home. Returns the installed gem dir.
+#[cfg(unix)]
+fn stage_system_home_copy(home: &Path, bin_dir: &Path) -> PathBuf {
+ use std::os::unix::fs::PermissionsExt;
+ let gem_dir = home.join("gems").join(format!("{DEP}-{DEP_VERSION}"));
+ std::fs::create_dir_all(gem_dir.join("lib")).unwrap();
+ std::fs::write(gem_dir.join("lib").join("stale_probe_gem.rb"), UPSTREAM_LIB).unwrap();
+ std::fs::create_dir_all(home.join("cache")).unwrap();
+ std::fs::write(
+ home.join("cache").join(format!("{DEP}-{DEP_VERSION}.gem")),
+ b"upstream-gem-archive-bytes",
+ )
+ .unwrap();
+ std::fs::create_dir_all(home.join("specifications")).unwrap();
+ std::fs::write(
+ home.join("specifications")
+ .join(format!("{DEP}-{DEP_VERSION}.gemspec")),
+ "# stub gemspec\n",
+ )
+ .unwrap();
+ std::fs::create_dir_all(bin_dir).unwrap();
+ let script = format!(
+ "#!/bin/sh\nif [ \"$1\" = env ] && [ \"$2\" = gemdir ]; then\n printf '%s\\n' \"{}\"\n exit 0\nfi\nexit 1\n",
+ home.display()
+ );
+ let bin = bin_dir.join("gem");
+ std::fs::write(&bin, script).unwrap();
+ std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
+ gem_dir
+}
+
+/// One `scan --mode hosted --json --vex` run over `proj` with `PATH`
+/// narrowed to `bin_dir` (the fake `gem`) plus `extra_env`.
+#[cfg(unix)]
+fn hosted_vex_scan_with_gem_on_path(
+ proj: &Path,
+ api: &str,
+ bin_dir: &Path,
+ extra_env: &[(&str, &str)],
+) -> (i32, serde_json::Value, String, PathBuf) {
+ let vex_path = proj.join("out.vex.json");
+ let path_env = bin_dir.to_str().unwrap().to_string();
+ let mut env: Vec<(&str, &str)> = vec![("PATH", path_env.as_str())];
+ env.extend_from_slice(extra_env);
+ let (code, stdout, stderr) = common::run_with_env(
+ proj,
+ &[
+ "scan",
+ "--mode",
+ "hosted",
+ "--json",
+ "--yes",
+ "--cwd",
+ proj.to_str().unwrap(),
+ "--api-url",
+ api,
+ "--org",
+ ORG,
+ "--api-token",
+ "fake",
+ "--vex",
+ vex_path.to_str().unwrap(),
+ "--vex-product",
+ "pkg:gem/app@1.0.0",
+ ],
+ &env,
+ );
+ let env = common::parse_json_envelope(&stdout);
+ (code, env, stderr, vex_path)
+}
+
+/// #1001: the project sets an explicit Bundler install `path` (local config
+/// or env `BUNDLE_PATH`) that isn't installed yet, as on every fresh clone
+/// or cold CI cache. Bundler then fetches non-default gems into that path
+/// and never reuses a copy in the machine's `gem env` home, so an unpatched
+/// copy there is not stale for this project: no warning, and the same
+/// run's `--vex` attests the purl (exit 0).
+#[cfg(unix)]
+#[tokio::test(flavor = "multi_thread")]
+async fn gem_hosted_explicit_bundle_path_ignores_system_home_copy() {
+ let server = MockServer::start().await;
+ mount_api(&server, None).await;
+ for via_env in [false, true] {
+ let tmp = tempfile::tempdir().unwrap();
+ let proj = tmp.path().join("proj");
+ std::fs::create_dir_all(&proj).unwrap();
+ write_manifest_pair(&proj);
+ let extra: &[(&str, &str)] = if via_env {
+ &[("BUNDLE_PATH", "vendor/bundle")]
+ } else {
+ std::fs::create_dir_all(proj.join(".bundle")).unwrap();
+ std::fs::write(
+ proj.join(".bundle").join("config"),
+ "---\nBUNDLE_PATH: \"vendor/bundle\"\n",
+ )
+ .unwrap();
+ &[]
+ };
+ let bin_dir = tmp.path().join("fake-bin");
+ let system_copy = stage_system_home_copy(&tmp.path().join("system-home"), &bin_dir);
+
+ let (code, env, stderr, vex_path) =
+ hosted_vex_scan_with_gem_on_path(&proj, &server.uri(), &bin_dir, extra);
+ assert!(
+ stale_warnings(&env).is_empty(),
+ "via_env={via_env}: bundler never reuses {} under an explicit path: {env}",
+ system_copy.display()
+ );
+ assert_eq!(
+ code, 0,
+ "via_env={via_env}: the run must attest, not fail.\nenvelope: {env}\nstderr:\n{stderr}"
+ );
+ let doc = std::fs::read_to_string(&vex_path).expect("VEX written");
+ assert!(
+ doc.contains(PURL),
+ "via_env={via_env}: purl not attested:\n{doc}"
+ );
+ }
+}
+
+/// #1001 control: with no Bundler `path` setting, `bundle install` installs
+/// into and reuses the `gem env` home, so a stale copy there still warns
+/// (shared-home flavor) and stays out of the same run's VEX. The same holds
+/// when a local `path.system: true` outranks an env `BUNDLE_PATH`: Bundler's
+/// first deciding tier turns system gems back on.
+#[cfg(unix)]
+#[tokio::test(flavor = "multi_thread")]
+async fn gem_hosted_system_install_still_flags_system_home_copy() {
+ let server = MockServer::start().await;
+ mount_api(&server, None).await;
+ for local_system_over_env_path in [false, true] {
+ let tmp = tempfile::tempdir().unwrap();
+ let proj = tmp.path().join("proj");
+ std::fs::create_dir_all(&proj).unwrap();
+ write_manifest_pair(&proj);
+ let extra: &[(&str, &str)] = if local_system_over_env_path {
+ std::fs::create_dir_all(proj.join(".bundle")).unwrap();
+ std::fs::write(
+ proj.join(".bundle").join("config"),
+ "---\nBUNDLE_PATH__SYSTEM: \"true\"\n",
+ )
+ .unwrap();
+ &[("BUNDLE_PATH", "vendor/bundle")]
+ } else {
+ &[]
+ };
+ let bin_dir = tmp.path().join("fake-bin");
+ let system_copy = stage_system_home_copy(&tmp.path().join("system-home"), &bin_dir);
+
+ let (code, env, stderr, vex_path) =
+ hosted_vex_scan_with_gem_on_path(&proj, &server.uri(), &bin_dir, extra);
+ let case = format!("local_system_over_env_path={local_system_over_env_path}");
+ let warnings = stale_warnings(&env);
+ assert_eq!(warnings.len(), 1, "{case}: {env}");
+ assert!(
+ warnings[0].contains(&system_copy.display().to_string())
+ && warnings[0].contains("shared gem home"),
+ "{case}: {}",
+ warnings[0]
+ );
+ if let Ok(doc) = std::fs::read_to_string(&vex_path) {
+ assert!(!doc.contains(PURL), "{case}: stale purl attested:\n{doc}");
+ }
+ assert_ne!(
+ code, 0,
+ "{case}: an all-stale --vex run must fail.\nstderr:\n{stderr}"
+ );
+ }
+}
+
+/// #729: run from the project root with `--cwd` left at its default (`.`).
+/// The project's own `vendor/bundle` is project-local whatever spelling
+/// `--cwd` has, so the stale copy there takes the delete-list remedy (not
+/// the "shared gem home" caveat), and the committed `vendor/cache`
+/// archive is folded into that warning instead of warning separately.
+#[tokio::test(flavor = "multi_thread")]
+async fn gem_hosted_default_cwd_keeps_project_local_remedy() {
+ let server = MockServer::start().await;
+ mount_api(&server, None).await;
+ let tmp = tempfile::tempdir().unwrap();
+ let proj = tmp.path().join("proj");
+ std::fs::create_dir_all(&proj).unwrap();
+ write_manifest_pair(&proj);
+ materialize_installed_gem(&proj, "3.3.0", UPSTREAM_LIB);
+ std::fs::create_dir_all(proj.join("vendor").join("cache")).unwrap();
+ std::fs::write(
+ proj.join("vendor")
+ .join("cache")
+ .join(format!("{DEP}-{DEP_VERSION}.gem")),
+ b"upstream-gem-archive-bytes",
+ )
+ .unwrap();
+
+ for cwd_args in [&[][..], &["--cwd", "."][..]] {
+ let mut args = vec!["scan", "--mode", "hosted", "--json", "--yes", "--api-url"];
+ let uri = server.uri();
+ args.push(&uri);
+ args.extend_from_slice(&["--org", ORG, "--api-token", "fake"]);
+ args.extend_from_slice(cwd_args);
+ let (code, stdout, stderr) = common::run_with_env(&proj, &args, &[]);
+ assert_eq!(
+ code, 0,
+ "args={args:?}\nstdout:\n{stdout}\nstderr:\n{stderr}"
+ );
+ let env = common::parse_json_envelope(&stdout);
+ let warnings = stale_warnings(&env);
+ assert_eq!(
+ warnings.len(),
+ 1,
+ "args={args:?}: one project-local warning with the cache archive folded in: {env}"
+ );
+ let detail = &warnings[0];
+ assert!(
+ detail.contains("Remove the stale") && !detail.contains("shared gem home"),
+ "args={args:?}: the project's own vendor/bundle is project-local: {detail}"
+ );
+ let cache_archive = Path::new("vendor")
+ .join("cache")
+ .join(format!("{DEP}-{DEP_VERSION}.gem"));
+ assert!(
+ detail.contains(&cache_archive.display().to_string()),
+ "args={args:?}: the committed cache archive belongs in the delete list: {detail}"
+ );
+ }
+}
diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs
index e44f8de45..f69adece7 100644
--- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs
+++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs
@@ -629,6 +629,63 @@ impl RubyCrawler {
.collect()
}
+ /// The gem homes `bundle install` installs into or reuses for this
+ /// project, each tagged project-local or shared, for the hosted
+ /// stale-install guard: a stale copy only matters where Bundler would
+ /// keep it instead of fetching the patched gem.
+ ///
+ /// Unlike [`Self::get_gem_paths`] (apply's write targets, which keep
+ /// the `gem env` homes for default gems), the `gem env` homes count
+ /// here only when Bundler uses system gems: no deployment store under
+ /// the default `vendor/bundle` and no explicit install `path`
+ /// ([`bundler_sets_explicit_path`]). The refused out-of-tree
+ /// config root ([`Self::verification_only_gem_paths`]) is included,
+ /// since Bundler installs into it. See [`bundler_gem_homes_from`] for
+ /// the project-local rule.
+ pub async fn bundler_install_homes(&self, options: &CrawlerOptions) -> Vec {
+ if options.global || options.global_prefix.is_some() {
+ let paths = self.get_gem_paths(options).await.unwrap_or_default();
+ return bundler_gem_homes_from(&options.cwd, &[], &[], &paths);
+ }
+ let discovery = Self::discover_bundle_stores(&options.cwd).await;
+ let verification = match &discovery.skipped_config_root {
+ Some(root) => Self::bundle_root_gems_dirs(root).await,
+ None => Vec::new(),
+ };
+ let ignore_config = bundler_ignores_config();
+ let uses_system_gems = !discovery.default_root_has_stores
+ && Self::has_bundler_manifest(&options.cwd).await
+ && !bundler_sets_explicit_path(BundlerPathTiers {
+ local: read_app_config(
+ &options.cwd,
+ std::env::var_os("BUNDLE_APP_CONFIG").as_deref(),
+ ignore_config,
+ )
+ .await,
+ env: BundlerPathSettings::from_env(
+ std::env::var_os("BUNDLE_PATH").as_deref(),
+ std::env::var_os("BUNDLE_PATH__SYSTEM").as_deref(),
+ std::env::var_os("BUNDLE_DISABLE_SHARED_GEMS").as_deref(),
+ ),
+ global: read_global_config(
+ ambient_bundler_global_config_file(&options.cwd).as_deref(),
+ ignore_config,
+ )
+ .await,
+ });
+ let system_homes = if uses_system_gems {
+ Self::gem_env_gems_dirs().await
+ } else {
+ Vec::new()
+ };
+ bundler_gem_homes_from(
+ &options.cwd,
+ &discovery.stores,
+ &verification,
+ &system_homes,
+ )
+ }
+
/// The installed-gem `gems/` dirs under one bundler install root, in
/// both layouts bundler produces:
///
@@ -1088,6 +1145,142 @@ fn verify_gem_at_path_sync(path: &Path) -> bool {
})
}
+/// One Bundler settings tier's `path`, `path.system` and
+/// `disable_shared_gems` values, each `None` when the tier doesn't set it
+/// (an empty string counts as set).
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
+pub(crate) struct BundlerPathSettings {
+ path: Option,
+ path_system: Option,
+ disable_shared_gems: Option,
+}
+
+impl BundlerPathSettings {
+ fn from_config_text(text: &str) -> Self {
+ Self {
+ path: bundle_config_setting_including_empty(text, "BUNDLE_PATH"),
+ path_system: bundle_config_setting_including_empty(text, "BUNDLE_PATH__SYSTEM"),
+ disable_shared_gems: bundle_config_setting_including_empty(
+ text,
+ "BUNDLE_DISABLE_SHARED_GEMS",
+ ),
+ }
+ }
+
+ fn from_env(
+ path: Option<&OsStr>,
+ path_system: Option<&OsStr>,
+ disable_shared_gems: Option<&OsStr>,
+ ) -> Self {
+ let text = |v: Option<&OsStr>| v.map(|v| v.to_string_lossy().into_owned());
+ Self {
+ path: text(path),
+ path_system: text(path_system),
+ disable_shared_gems: text(disable_shared_gems),
+ }
+ }
+}
+
+/// The settings tiers Bundler's `Settings#path` reads, highest first: the
+/// app config (`local`) and global config texts (`None` when missing or
+/// under `BUNDLE_IGNORE_CONFIG`) and the environment.
+pub(crate) struct BundlerPathTiers {
+ pub(crate) local: Option,
+ pub(crate) env: BundlerPathSettings,
+ pub(crate) global: Option,
+}
+
+/// Whether Bundler installs into an explicit `path` instead of the system
+/// gems, following `Bundler::Settings#path`: the first tier (local, env,
+/// global) that sets `path`, `path.system` or `disable_shared_gems` decides
+/// alone, and it uses system gems when `path.system` is truthy or
+/// `disable_shared_gems` is falsy ([`bundler_truthy`]). Bundler never reuses a `gem env` copy
+/// of a non-default gem under an explicit path (`use_system_gems?` is
+/// false).
+///
+/// An empty `path` counts as not explicit, so the caller keeps judging the
+/// system homes: when unsure, it's safer to warn than to skip a copy
+/// Bundler may load.
+pub(crate) fn bundler_sets_explicit_path(tiers: BundlerPathTiers) -> bool {
+ let settings = [
+ tiers
+ .local
+ .as_deref()
+ .map(BundlerPathSettings::from_config_text),
+ Some(tiers.env),
+ tiers
+ .global
+ .as_deref()
+ .map(BundlerPathSettings::from_config_text),
+ ];
+ for tier in settings.into_iter().flatten() {
+ if tier.path.is_none() && tier.path_system.is_none() && tier.disable_shared_gems.is_none() {
+ continue;
+ }
+ // Both flags go through Bundler's `to_bool` coercion.
+ let system = tier.path_system.as_deref().is_some_and(bundler_truthy)
+ || tier
+ .disable_shared_gems
+ .as_deref()
+ .is_some_and(|v| !bundler_truthy(v));
+ return !system && tier.path.is_some_and(|p| !p.is_empty());
+ }
+ false
+}
+
+/// One gem home from [`RubyCrawler::bundler_install_homes`].
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct BundlerGemHome {
+ /// The home's `gems/` dir.
+ pub gems_dir: PathBuf,
+ /// The home belongs to this project (under the project root, or the
+ /// project's own `.bundle/config` path even when that sits outside the
+ /// tree), as opposed to a gem home other projects share.
+ pub project_local: bool,
+}
+
+/// Tag `stores` (the Bundler install stores), `config_stores` (the refused
+/// out-of-tree `.bundle/config` root's stores) and `shared_homes` (the
+/// `gem env` homes) with their [`BundlerGemHome::project_local`] flag,
+/// deduped in that order.
+///
+/// Containment is decided on absolute, lexically normalized paths, so the
+/// answer doesn't depend on how `--cwd` is spelled: with the default `.`,
+/// the discovered stores come back as `vendor/bundle/…`, which no lexical
+/// `starts_with(".")` matches.
+pub fn bundler_gem_homes_from(
+ project_root: &Path,
+ stores: &[PathBuf],
+ config_stores: &[PathBuf],
+ shared_homes: &[PathBuf],
+) -> Vec {
+ fn absolute(path: &Path) -> Option {
+ let abs = std::path::absolute(path).ok()?;
+ Some(normalize_lexically(&abs).unwrap_or(abs))
+ }
+ let base = absolute(project_root).filter(|b| !b.as_os_str().is_empty());
+ let under_root = |dir: &Path| match (&base, absolute(dir)) {
+ (Some(base), Some(dir)) => dir.starts_with(base),
+ _ => false,
+ };
+ let mut seen = HashSet::new();
+ let mut homes = Vec::new();
+ let tagged = stores
+ .iter()
+ .map(|d| (d, under_root(d)))
+ .chain(config_stores.iter().map(|d| (d, true)))
+ .chain(shared_homes.iter().map(|d| (d, under_root(d))));
+ for (gems_dir, project_local) in tagged {
+ if seen.insert(gems_dir.clone()) {
+ homes.push(BundlerGemHome {
+ gems_dir: gems_dir.clone(),
+ project_local,
+ });
+ }
+ }
+ homes
+}
+
/// Result of probing the Bundler install roots.
///
/// Public so CLI consumers (apply's store-class split, scan/apply's
@@ -3621,6 +3814,139 @@ mod tests {
);
}
+ /// #1001: Bundler installs into an explicit `path` only when the first
+ /// tier (local, env, global) that sets `path`, `path.system` or
+ /// `disable_shared_gems` sets a non-empty path without turning system
+ /// gems back on. A higher tier's `path.system: true` beats a lower
+ /// tier's path (Bugbot on #1002).
+ #[test]
+ fn bundler_sets_explicit_path_follows_settings_tiers() {
+ let env = |path: Option<&str>, system: Option<&str>, disable: Option<&str>| {
+ BundlerPathSettings::from_env(
+ path.map(OsStr::new),
+ system.map(OsStr::new),
+ disable.map(OsStr::new),
+ )
+ };
+ let tiers = |local: Option<&str>, env: BundlerPathSettings, global: Option<&str>| {
+ bundler_sets_explicit_path(BundlerPathTiers {
+ local: local.map(str::to_string),
+ env,
+ global: global.map(str::to_string),
+ })
+ };
+ let none = || env(None, None, None);
+ let local_path = "---\nBUNDLE_PATH: \"vendor/bundle\"\n";
+ let local_system = "---\nBUNDLE_PATH__SYSTEM: \"true\"\n";
+
+ assert!(!tiers(None, none(), None), "no setting: system gems");
+ assert!(tiers(Some(local_path), none(), None), "local path");
+ assert!(
+ tiers(None, env(Some("vendor/bundle"), None, None), None),
+ "env path"
+ );
+ assert!(
+ tiers(None, none(), Some("---\nBUNDLE_PATH: \"/opt/bundle\"\n")),
+ "global path"
+ );
+ assert!(
+ !tiers(
+ Some("---\nBUNDLE_PATH: \"vendor/bundle\"\nBUNDLE_PATH__SYSTEM: \"true\"\n"),
+ none(),
+ None
+ ),
+ "path.system in the same tier"
+ );
+ assert!(
+ !tiers(
+ Some(local_system),
+ env(Some("vendor/bundle"), None, None),
+ None
+ ),
+ "a local path.system beats an env path"
+ );
+ assert!(
+ !tiers(None, env(Some("vendor/bundle"), Some("true"), None), None),
+ "env path.system beside the env path"
+ );
+ assert!(
+ !tiers(None, env(Some("vendor/bundle"), Some("1"), None), None),
+ "path.system goes through Bundler's to_bool"
+ );
+ assert!(
+ tiers(None, env(Some("vendor/bundle"), Some("no"), None), None),
+ "a falsy path.system keeps the explicit path"
+ );
+ assert!(
+ !tiers(None, env(Some("vendor/bundle"), None, Some("no")), None),
+ "a falsy disable_shared_gems turns system gems back on"
+ );
+ assert!(
+ !tiers(None, env(None, None, Some("false")), Some(local_path)),
+ "env disable_shared_gems=false decides before the global path"
+ );
+ assert!(
+ tiers(Some(local_path), env(None, Some("true"), None), None),
+ "a local path beats an env path.system"
+ );
+ assert!(
+ !tiers(None, env(Some(""), None, None), Some(local_path)),
+ "an empty env path stops at the env tier and isn't explicit"
+ );
+ assert!(
+ !tiers(
+ Some("---\nBUNDLE_PATH__SYSTEM: \"false\"\n"),
+ env(Some("vendor/bundle"), None, None),
+ None
+ ),
+ "a local path.system=false stops at the local tier with no path"
+ );
+ }
+
+ /// #729: project-local tagging compares absolute, normalized paths, so a
+ /// relative `--cwd` (the default `.`) still tags the project's own
+ /// `vendor/bundle` store local. Stores outside the root and `gem env`
+ /// homes are shared, refused config stores are local, and a home
+ /// reachable two ways is listed once.
+ #[test]
+ fn bundler_gem_homes_from_tags_project_local_by_absolute_path() {
+ let cwd = std::env::current_dir().unwrap();
+ let local_store = Path::new("vendor")
+ .join("bundle")
+ .join("ruby")
+ .join("3.3.0")
+ .join("gems");
+ let outside = std::env::temp_dir().join("sp-shared-home").join("gems");
+ let config_store = std::env::temp_dir().join("sp-config-root").join("gems");
+ for project_root in [Path::new("."), Path::new(""), cwd.as_path()] {
+ let homes = bundler_gem_homes_from(
+ project_root,
+ &[local_store.clone(), outside.clone()],
+ &[config_store.clone()],
+ &[
+ outside.clone(),
+ cwd.join("vendor").join("rubies").join("gems"),
+ ],
+ );
+ let tag = |dir: &Path| {
+ homes
+ .iter()
+ .find(|h| h.gems_dir == dir)
+ .map(|h| h.project_local)
+ };
+ assert_eq!(homes.len(), 4, "root {project_root:?}: {homes:?}");
+ // An empty root has no base to contain anything.
+ let expect_local = !project_root.as_os_str().is_empty();
+ assert_eq!(
+ tag(&local_store),
+ Some(expect_local),
+ "root {project_root:?}"
+ );
+ assert_eq!(tag(&outside), Some(false), "root {project_root:?}");
+ assert_eq!(tag(&config_store), Some(true), "root {project_root:?}");
+ }
+ }
+
/// Stage a project that used to install into `vendor/bundle` (the
/// store is still on disk, gitignored) and returns that store.
async fn stage_leftover_vendor_bundle(root: &Path) -> PathBuf {