From 6a5b5d9187b0f53f8dc4935c37ac88617e6f8c48 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 11:36:12 +0000 Subject: [PATCH 1/5] Start fix for #1001, #729 Assisted-by: Claude Code:claude-opus-5-5 From 5e3eb6c9428bd50fbe58ad4e3daed2cf0b396189 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 11:48:59 +0000 Subject: [PATCH 2/5] Tell which gem homes Bundler really uses The hosted stale-install guard needs to know which gem homes `bundle install` installs into or reuses, and which of those belong to the project. The crawler's flat get_gem_paths list can't say: it keeps the `gem env` homes for apply's default-gem fallback even when the project sets its own Bundler `path`, and it gives relative dirs for the default `--cwd .`. Record in BundleStoreDiscovery whether an explicit install path is configured (app config, env BUNDLE_PATH or global config), and add RubyCrawler::bundler_install_homes. It returns the install stores, the refused out-of-tree config root, and the `gem env` homes only when Bundler uses system gems. Each home is tagged project-local by comparing absolute, normalized paths. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/ruby_crawler.rs | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 6a6f99c93..ee86ee629 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -366,10 +366,12 @@ impl RubyCrawler { let mut roots: Vec = Vec::new(); let mut skipped_config_path = None; let mut skipped_config_root = None; + let mut explicit_path = false; if Self::has_bundler_manifest(cwd).await { if let Some(value) = Self::app_config_bundle_path(cwd, app_config_env, ignore_config).await { + explicit_path = true; match resolve_config_bundle_path(cwd, &value, home) { Some(root) => roots.push(root), // Refused by the containment guard. Recorded — not @@ -386,6 +388,7 @@ impl RubyCrawler { } } if let Some(v) = bundle_path_env.filter(|v| !v.is_empty()) { + explicit_path = true; roots.push(resolve_bundle_path(cwd, Path::new(v), home)); } let standalone_root = cwd.join("bundle"); @@ -410,6 +413,7 @@ impl RubyCrawler { .await .and_then(|text| parse_bundle_config_path(&text)) { + explicit_path = true; roots.push(resolve_bundle_path(cwd, Path::new(&value), home)); } } @@ -439,6 +443,7 @@ impl RubyCrawler { default_root_has_stores, skipped_config_path, skipped_config_root, + explicit_path, } } @@ -544,6 +549,45 @@ 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` + /// ([`BundleStoreDiscovery::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 uses_system_gems = !discovery.default_root_has_stores + && !discovery.explicit_path + && Self::has_bundler_manifest(&options.cwd).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: /// @@ -969,6 +1013,59 @@ fn verify_gem_at_path_sync(path: &Path) -> bool { }) } +/// 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 @@ -998,6 +1095,15 @@ pub struct BundleStoreDiscovery { /// normalized). Never a write target: only /// [`RubyCrawler::verification_only_gem_paths`] probes it, read-only. pub skipped_config_root: Option, + /// Whether the deciding Bundler tier sets an explicit install `path` + /// (the app config's `BUNDLE_PATH`, accepted or refused by the + /// containment guard, a non-empty env `BUNDLE_PATH`, or the global + /// config's). Bundler then installs every non-default gem into that + /// path and never reuses a copy in the `gem env` homes + /// (`use_system_gems?` is false), so those homes hold nothing + /// `bundle install` would keep for this project. A truthy + /// `path.system` drops the path before it gets here. + pub explicit_path: bool, } /// The stable warning `(code, detail)` for a config-sourced `BUNDLE_PATH` @@ -3200,6 +3306,113 @@ mod tests { ); } + /// #1001: `explicit_path` records that the deciding Bundler tier sets + /// an install `path` (local config, accepted or refused, a non-empty + /// env `BUNDLE_PATH`, or the global config), whether or not anything + /// is installed there yet. No setting, an empty env value, or a local + /// `path.system: true` leaves Bundler on the system gems. + #[tokio::test] + async fn discovery_records_explicit_install_path() { + async fn discover(config: Option<&str>, env: Option<&str>, global: Option<&str>) -> bool { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Gemfile"), b"gem \"foo\"\n").unwrap(); + if let Some(text) = config { + std::fs::create_dir_all(dir.path().join(".bundle")).unwrap(); + std::fs::write(dir.path().join(".bundle").join("config"), text).unwrap(); + } + let global_file = global.map(|text| { + let file = dir.path().join("global-config"); + std::fs::write(&file, text).unwrap(); + file + }); + RubyCrawler::discover_bundle_stores_with_env( + dir.path(), + env.map(OsStr::new), + None, + None, + global_file.as_deref(), + ) + .await + .explicit_path + } + assert!(!discover(None, None, None).await, "no setting"); + assert!( + discover(Some("---\nBUNDLE_PATH: \"vendor/bundle\"\n"), None, None).await, + "local config path, nothing installed yet" + ); + assert!( + discover( + Some("---\nBUNDLE_PATH: \"/elsewhere/bundle\"\n"), + None, + None + ) + .await, + "refused out-of-tree config path: bundler still installs there" + ); + assert!( + discover(None, Some("vendor/bundle"), None).await, + "env path" + ); + assert!(!discover(None, Some(""), None).await, "empty env path"); + assert!( + discover(None, None, Some("---\nBUNDLE_PATH: \"/opt/bundle\"\n")).await, + "global config path" + ); + assert!( + !discover( + Some("---\nBUNDLE_PATH: \"vendor/bundle\"\nBUNDLE_PATH__SYSTEM: \"true\"\n"), + None, + None + ) + .await, + "path.system true means the system gems" + ); + } + + /// #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:?}"); + } + } + /// Pure parser contract for the `.bundle/config` scrape: bundler's /// own quoted form, unquoted and single-quoted variants, CRLF, /// empty-value-as-unset, and no match on `BUNDLE_PATH__SYSTEM:` or From 9dd72b441f4f1abe12a596996e4c7804e49c5a6b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 11:48:59 +0000 Subject: [PATCH 3/5] Fix false stale-gem warnings in hosted scans `scan --mode hosted` flagged an unpatched copy in the machine's gem home as stale even when the project sets a Bundler `path`. Bundler never reuses that copy, but the warning dropped the gem from the same run's VEX, so `scan --mode hosted --vex` failed with no_applicable_patches on every fresh checkout (#1001). With the default `--cwd .`, a stale copy in the project's own vendor/bundle was called a "shared gem home" with a remedy that does nothing, and the committed vendor/cache archive was left out of the delete list (#729). The guard now uses RubyCrawler::bundler_install_homes, so it judges only the homes Bundler uses and takes the project-local tag from the crawler instead of a lexical starts_with(cwd). Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../src/commands/scan/hosted.rs | 38 ++-- .../tests/e2e_redirect_gem_stale_install.rs | 214 ++++++++++++++++++ 3 files changed, 235 insertions(+), 19 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index dff38f2c8..7b66368e6 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -165,7 +165,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). -**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 no explicit install `path` from the app config, the environment or the global config, since with a `path` set `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 e6e48a140..42d07deff 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -297,7 +297,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): this run's fetched records first, then the redirect /// ledger's persisted ones — a re-scan whose `/patches/view` fetch failed @@ -361,6 +362,7 @@ async fn gem_stale_install_warnings( leaf: String, patched: bool, positive: bool, + project_local: bool, } let crawler = RubyCrawler::new(); let options = CrawlerOptions { @@ -368,17 +370,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. @@ -386,9 +385,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(); @@ -396,7 +398,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; }; @@ -413,6 +415,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 { @@ -433,8 +436,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 24817325f..3b3bf9bcf 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 @@ -1175,3 +1175,217 @@ 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. +#[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; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + write_manifest_pair(&proj); + 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, &[]); + let warnings = stale_warnings(&env); + assert_eq!(warnings.len(), 1, "{env}"); + assert!( + warnings[0].contains(&system_copy.display().to_string()) + && warnings[0].contains("shared gem home"), + "{}", + warnings[0] + ); + if let Ok(doc) = std::fs::read_to_string(&vex_path) { + assert!(!doc.contains(PURL), "stale purl attested:\n{doc}"); + } + assert_ne!( + code, 0, + "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}" + ); + } +} From 5ed25cd891d16587e59a6df4957fd6f39f798aec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 12:31:53 +0000 Subject: [PATCH 4/5] Honor path.system from a higher Bundler tier The stale-install guard skipped the machine's gem homes whenever any tier set a Bundler install path. But Bundler takes `path`, `path.system` and `disable_shared_gems` from the first tier (local, env, global) that sets any of them. So a local `path.system: true` puts Bundler back on system gems even when BUNDLE_PATH is set in the environment. In that setup a stale system copy wasn't warned about, and the same run's VEX could attest it. Decide this the way Bundler's Settings#path does, with bundler_sets_explicit_path, and drop the discovery flag that ignored tier order. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/e2e_redirect_gem_stale_install.rs | 62 +++-- .../src/crawlers/ruby_crawler.rs | 227 +++++++++++++----- 3 files changed, 202 insertions(+), 89 deletions(-) diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 7b66368e6..0a4a1efd1 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -165,7 +165,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and, for a Gradle build, every settings, build, `buildSrc`, included-build, applied and plugin-source script, version catalog and lock file the script graph reaches, plus `gradle/verification-metadata.xml`, `gradle/wrapper/gradle-wrapper.properties` and the owned `.socket/gradle/` files). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (the pin yarn writes for a root `resolutions` entry: the root `package.json` — edited only beside a berry `yarn.lock` — gains one `"@npm:": ""` selector per locked range (`redirect_yarn_berry_resolution` edits), and only that `yarn.lock` entry is re-keyed `"@"` with the same `resolution:` + `yarnBerry10c0` checksum (`redirect_yarn_berry_entry`), moved to yarn's key order; never an `npm:` locator, whose fetcher sends npm registry auth to the patch host, nor a tarball locator under an `npm:` key, which hardened mode rejects (YN0078). An older release's `npm:::__archiveUrl=` pin is still recognized and is re-pinned on the next run; rollback rebuilds the key from the selectors and drops them. Refused, nothing written: a user-authored `resolutions` entry for the package `redirect_yarn_berry_resolutions_conflict`, no root manifest `redirect_yarn_berry_manifest_missing`, a builtin `patch:` entry wrapping the same descriptor `redirect_yarn_berry_shared_descriptor`, an artifact URL yarn cannot fetch as a tarball `redirect_yarn_berry_artifact_url_unsupported`; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). **gradle** (v5.0) is automated wiring, no longer a pasted snippet: the owned settings script `.socket/gradle/socket-patch.hosted.settings.gradle` with its index `.socket/gradle/hosted-index.tsv`, one apply line per build's settings file, every lock entry of the GA moved to the suffixed version, and the suffixed component in an existing `gradle/verification-metadata.xml`. A refused dep writes nothing and keeps `redirect_gradle_manual_snippet` as its fallback; same-GAV grants are refused (`redirect_gradle_same_gav_unsupported`). Rules, refusals and codes: [Gradle builds](#gradle-builds-v50). -**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 no explicit install `path` from the app config, the environment or the global config, since with a `path` set `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. +**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/tests/e2e_redirect_gem_stale_install.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs index 3b3bf9bcf..8024ca32c 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 @@ -1302,36 +1302,52 @@ async fn gem_hosted_explicit_bundle_path_ignores_system_home_copy() { /// #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. +/// (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; - let tmp = tempfile::tempdir().unwrap(); - let proj = tmp.path().join("proj"); - std::fs::create_dir_all(&proj).unwrap(); - write_manifest_pair(&proj); - let bin_dir = tmp.path().join("fake-bin"); - let system_copy = stage_system_home_copy(&tmp.path().join("system-home"), &bin_dir); + 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, &[]); - let warnings = stale_warnings(&env); - assert_eq!(warnings.len(), 1, "{env}"); - assert!( - warnings[0].contains(&system_copy.display().to_string()) - && warnings[0].contains("shared gem home"), - "{}", - warnings[0] - ); - if let Ok(doc) = std::fs::read_to_string(&vex_path) { - assert!(!doc.contains(PURL), "stale purl attested:\n{doc}"); + 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}" + ); } - assert_ne!( - code, 0, - "an all-stale --vex run must fail.\nstderr:\n{stderr}" - ); } /// #729: run from the project root with `--cwd` left at its default (`.`). diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index ee86ee629..1024ae74a 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -366,12 +366,10 @@ impl RubyCrawler { let mut roots: Vec = Vec::new(); let mut skipped_config_path = None; let mut skipped_config_root = None; - let mut explicit_path = false; if Self::has_bundler_manifest(cwd).await { if let Some(value) = Self::app_config_bundle_path(cwd, app_config_env, ignore_config).await { - explicit_path = true; match resolve_config_bundle_path(cwd, &value, home) { Some(root) => roots.push(root), // Refused by the containment guard. Recorded — not @@ -388,7 +386,6 @@ impl RubyCrawler { } } if let Some(v) = bundle_path_env.filter(|v| !v.is_empty()) { - explicit_path = true; roots.push(resolve_bundle_path(cwd, Path::new(v), home)); } let standalone_root = cwd.join("bundle"); @@ -413,7 +410,6 @@ impl RubyCrawler { .await .and_then(|text| parse_bundle_config_path(&text)) { - explicit_path = true; roots.push(resolve_bundle_path(cwd, Path::new(&value), home)); } } @@ -443,7 +439,6 @@ impl RubyCrawler { default_root_has_stores, skipped_config_path, skipped_config_root, - explicit_path, } } @@ -558,7 +553,7 @@ impl RubyCrawler { /// 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` - /// ([`BundleStoreDiscovery::explicit_path`]). The refused out-of-tree + /// ([`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. @@ -572,9 +567,27 @@ impl RubyCrawler { 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 - && !discovery.explicit_path - && Self::has_bundler_manifest(&options.cwd).await; + && 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 { @@ -1013,6 +1026,85 @@ 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 true or +/// `disable_shared_gems` is false. 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; + } + let system = tier.path_system.as_deref() == Some("true") + || tier.disable_shared_gems.as_deref() == Some("false"); + 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 { @@ -1095,15 +1187,6 @@ pub struct BundleStoreDiscovery { /// normalized). Never a write target: only /// [`RubyCrawler::verification_only_gem_paths`] probes it, read-only. pub skipped_config_root: Option, - /// Whether the deciding Bundler tier sets an explicit install `path` - /// (the app config's `BUNDLE_PATH`, accepted or refused by the - /// containment guard, a non-empty env `BUNDLE_PATH`, or the global - /// config's). Bundler then installs every non-default gem into that - /// path and never reuses a copy in the `gem env` homes - /// (`use_system_gems?` is false), so those homes hold nothing - /// `bundle install` would keep for this project. A truthy - /// `path.system` drops the path before it gets here. - pub explicit_path: bool, } /// The stable warning `(code, detail)` for a config-sourced `BUNDLE_PATH` @@ -3306,66 +3389,80 @@ mod tests { ); } - /// #1001: `explicit_path` records that the deciding Bundler tier sets - /// an install `path` (local config, accepted or refused, a non-empty - /// env `BUNDLE_PATH`, or the global config), whether or not anything - /// is installed there yet. No setting, an empty env value, or a local - /// `path.system: true` leaves Bundler on the system gems. - #[tokio::test] - async fn discovery_records_explicit_install_path() { - async fn discover(config: Option<&str>, env: Option<&str>, global: Option<&str>) -> bool { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("Gemfile"), b"gem \"foo\"\n").unwrap(); - if let Some(text) = config { - std::fs::create_dir_all(dir.path().join(".bundle")).unwrap(); - std::fs::write(dir.path().join(".bundle").join("config"), text).unwrap(); - } - let global_file = global.map(|text| { - let file = dir.path().join("global-config"); - std::fs::write(&file, text).unwrap(); - file - }); - RubyCrawler::discover_bundle_stores_with_env( - dir.path(), - env.map(OsStr::new), - None, - None, - global_file.as_deref(), + /// #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), ) - .await - .explicit_path - } - assert!(!discover(None, None, None).await, "no setting"); + }; + 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!( - discover(Some("---\nBUNDLE_PATH: \"vendor/bundle\"\n"), None, None).await, - "local config path, nothing installed yet" + tiers(None, env(Some("vendor/bundle"), None, None), None), + "env path" ); assert!( - discover( - Some("---\nBUNDLE_PATH: \"/elsewhere/bundle\"\n"), - None, + 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 - ) - .await, - "refused out-of-tree config path: bundler still installs there" + ), + "path.system in the same tier" ); assert!( - discover(None, Some("vendor/bundle"), None).await, - "env path" + !tiers( + Some(local_system), + env(Some("vendor/bundle"), None, None), + None + ), + "a local path.system beats an env path" ); - assert!(!discover(None, Some(""), None).await, "empty env path"); assert!( - discover(None, None, Some("---\nBUNDLE_PATH: \"/opt/bundle\"\n")).await, - "global config path" + !tiers(None, env(Some("vendor/bundle"), Some("true"), None), None), + "env path.system beside the env path" ); assert!( - !discover( - Some("---\nBUNDLE_PATH: \"vendor/bundle\"\nBUNDLE_PATH__SYSTEM: \"true\"\n"), - None, + !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 - ) - .await, - "path.system true means the system gems" + ), + "a local path.system=false stops at the local tier with no path" ); } From d65c5c43a998597240314b763cb63a8fd2d466e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 13:20:13 +0000 Subject: [PATCH 5/5] Drop stale entries from the digest pending list The digest guard test is red on main: #955 added crawlers/gradle_cache.rs, patch/jvm_jar.rs and patch/sidecars/maven.rs to the pending list, and #690 had already moved them onto the utils::digest helpers. This ports the same three-line change as #1016, so it becomes a no-op once #1016 lands. Assisted-by: Claude Code:claude-opus-5-5 --- crates/socket-patch-core/src/utils/digest.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/socket-patch-core/src/utils/digest.rs b/crates/socket-patch-core/src/utils/digest.rs index 105225e5e..630adefa1 100644 --- a/crates/socket-patch-core/src/utils/digest.rs +++ b/crates/socket-patch-core/src/utils/digest.rs @@ -135,9 +135,6 @@ mod tests { /// when you move it onto the helpers above; the test fails on a stale /// entry as well as on a new inline copy. const PENDING_INLINE_DIGESTS: &[&str] = &[ - "crawlers/gradle_cache.rs", - "patch/jvm_jar.rs", - "patch/sidecars/maven.rs", "utils/group_commit.rs", "vendor/jvm/mod.rs", "vendor/maven_repo.rs",