diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 83bb766d6..cbedc9470 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -179,7 +179,7 @@ The rewriter reads a fixed set of candidate files from the project root: the npm **Non-UTF-8 candidate files (#721)**: the rewriters edit UTF-8 text only. A candidate file that exists but is not UTF-8 (for example a UTF-16 `requirements.txt`, which is what Windows PowerShell 5.1's `pip freeze >` writes and which pip installs from) is never read as absent. When a candidate of its ecosystem could rewrite it, the run is refused with `candidate_file_unreadable` (exit 1, `--dry-run` included), the message names the file, and nothing is written; the remedy is to re-save the file as UTF-8. Two exceptions keep their own refusals. A Gradle build file the Gradle planner reaches gets that planner's per-build refusal (`redirect_gradle_build_file_unreadable`, exit 0) and the rest of the run goes ahead, and with no readable Gradle build a stray Gradle file (a lock, a nested script) is never rewritten, so it does not refuse the run, while a non-UTF-8 root `settings.gradle(.kts)` or `build.gradle(.kts)` still refuses it (it may be the build itself). An unreadable `socket-patch.sbt` is refused with `redirect_sbt_owned_file_unreadable`. A vendored→hosted takeover checks this before it reverts anything, so a refused run leaves the vendored wiring, ledger entry and artifact byte-identical. Vendored mode likewise refuses a non-UTF-8 `requirements.txt` or `-r` include by name (`pypi_no_requirements`) instead of wiring around it. Lock-only discovery reads `requirements.txt` and its in-root `-r` includes the way pip decodes them (a UTF-16 or UTF-32 byte-order mark selects that encoding), so such a project's pins are still found instead of reporting "No packages found". -**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. +**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`, and, when no tier sets any of those, no truthy `deployment` (the first tier that sets it wins; it makes `vendor/bundle` the path), since with such a `path` `bundle install` fetches non-default gems into it and never reuses a system copy; the `.bundle` default that `default_install_uses_path` (Bundler 2.x) or `simulate_version 5` (Bundler 4.x) selects depends on the Bundler that runs, so those flags keep the `gem env` homes judged) 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). Standalone `vex` and `apply --check` judge gem copies by the same rule (#1098): when Bundler doesn't use system gems, a copy in a `gem env` home is not one the project loads and is not verified, unless it is a default gem (its spec under `specifications/default/`), which Bundler loads from the system home under any path. 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/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index 8e14a6502..7113fa392 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -25,7 +25,7 @@ //! | maven | `///-socket./` (the version the pom or the hosted Gradle wiring pins) in `~/.m2` and every Gradle `files-2.1` holding it (hash dirs expanded), its artifact files matched under the suffixed name | the `` version dir | //! | npm | every `node_modules` copy the crawler finds (pnpm and vlt store copies included), every peer / modifier / registry variant of those in the same `.pnpm` / `.vlt` store, alias installs (`node_modules/` holding the package) in the root's and every workspace member's tree included | — each serves some dependent: ALL must verify | //! | pypi | every copy in the crawler's environment set (the project's venvs when it has any, else the interpreters) | — any may be the one that runs the project: ALL must verify | -//! | gem | every copy in bundler's gem path | — bundler loads whichever `Gem.path` home it hits first: ALL must verify | +//! | gem | every copy in bundler's gem path; under an explicit or deployment `path` the `gem env` homes hold only default gems bundler loads (#1098) | a non-default gem's `gem env` copy when bundler doesn't use system gems; otherwise bundler loads whichever `Gem.path` home it hits first: ALL must verify | //! //! composer and nuget have ONE install location shared by every source //! (`vendor/`, the global packages folder — which restore reuses whatever diff --git a/crates/socket-patch-cli/src/ecosystem_dispatch.rs b/crates/socket-patch-cli/src/ecosystem_dispatch.rs index 76796c000..10a43cfb4 100644 --- a/crates/socket-patch-cli/src/ecosystem_dispatch.rs +++ b/crates/socket-patch-cli/src/ecosystem_dispatch.rs @@ -707,6 +707,26 @@ pub async fn find_manifest_package_copies_reusing( .map(|(_, paths)| paths), ) .await; + // A `gem env` home Bundler never loads this project's gems from (an + // explicit or deployment `path`) holds no copy the project runs, so an + // unpatched one there must not block the attestation (#1098). Default + // gems stay: Bundler loads those from the system home under any path. + if partitioned.contains_key(&Ecosystem::Gem) { + let unused = RubyCrawler + .bundler_unused_system_gem_homes(&crawler_options) + .await; + if !unused.is_empty() { + for (_, paths) in copies + .iter_mut() + .filter(|(purl, _)| purl.starts_with("pkg:gem/")) + { + paths.retain(|path| { + !unused.iter().any(|home| path.starts_with(home)) + || socket_patch_core::crawlers::ruby_crawler::is_default_gem_copy(path) + }); + } + } + } copies.retain(|_, paths| !paths.is_empty()); // Verification also READS a `.bundle/config` bundle path the crawler // refused as a write root (it resolves outside the project): bundler diff --git a/crates/socket-patch-cli/tests/e2e_hosted_production.rs b/crates/socket-patch-cli/tests/e2e_hosted_production.rs index 6c7ca6f07..f293592a0 100644 --- a/crates/socket-patch-cli/tests/e2e_hosted_production.rs +++ b/crates/socket-patch-cli/tests/e2e_hosted_production.rs @@ -33,7 +33,7 @@ //! //! | Ecosystem | PURL | Patch UUID | Advisory | //! |-----------|------|------------|----------| -//! | npm | `pkg:npm/minimist@1.2.2` | `80630680-4da6-45f9-bba8-b888e0ffd58c` | GHSA-xvch-5gv4-984h (CVE-2021-44906) | +//! | npm | `pkg:npm/minimist@1.2.2` | `642d7f02-ebc1-4ab0-99e2-07f5dd8463cb` | GHSA-xvch-5gv4-984h (CVE-2021-44906) | //! | PyPI | `pkg:pypi/urllib3@1.26.18` | *any of three* (see [`PYPI_UUIDS`]) | GHSA-gm62-xv2j-4w53 &co | //! | gem | `pkg:gem/activestorage@6.0.3` | *any of* [`GEM_UUIDS`] (six today; the sixth merges three advisories) | GHSA-m42x-37p3-fv5w (CVE-2020-8162), GHSA-w749-p3v6-hccq (CVE-2022-21831), GHSA-9xrj-h377-fr87 (CVE-2026-33195), GHSA-r4mg-4433-c7g3 (CVE-2025-24293), GHSA-xr9x-r78c-5hrm (CVE-2026-66066) | //! @@ -123,7 +123,7 @@ const PATCH_HOST: &str = "patch.socket.dev"; const NPM_PURL: &str = "pkg:npm/minimist@1.2.2"; const NPM_NAME: &str = "minimist"; const NPM_VERSION: &str = "1.2.2"; -const NPM_UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; +const NPM_UUID: &str = "642d7f02-ebc1-4ab0-99e2-07f5dd8463cb"; const PYPI_PURL: &str = "pkg:pypi/urllib3@1.26.18"; const PYPI_NAME: &str = "urllib3"; diff --git a/crates/socket-patch-cli/tests/e2e_npm.rs b/crates/socket-patch-cli/tests/e2e_npm.rs index 63de6c636..4df29c4d7 100644 --- a/crates/socket-patch-cli/tests/e2e_npm.rs +++ b/crates/socket-patch-cli/tests/e2e_npm.rs @@ -1,7 +1,7 @@ //! End-to-end tests for the npm patch lifecycle. //! //! These tests exercise the full CLI against the real Socket API, using the -//! **minimist@1.2.2** patch (UUID `80630680-4da6-45f9-bba8-b888e0ffd58c`), +//! **minimist@1.2.2** patch (UUID `642d7f02-ebc1-4ab0-99e2-07f5dd8463cb`), //! which fixes CVE-2021-44906 (Prototype Pollution). //! //! # Prerequisites @@ -26,14 +26,14 @@ use common::cache_env; // Constants // --------------------------------------------------------------------------- -const NPM_UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; +const NPM_UUID: &str = "642d7f02-ebc1-4ab0-99e2-07f5dd8463cb"; const NPM_PURL: &str = "pkg:npm/minimist@1.2.2"; /// Git SHA-256 of the *unpatched* `index.js` shipped with minimist 1.2.2. const BEFORE_HASH: &str = "311f1e893e6eac502693fad8617dcf5353a043ccc0f7b4ba9fe385e838b67a10"; /// Git SHA-256 of the *patched* `index.js` after the security fix. -const AFTER_HASH: &str = "043f04d19e884aa5f8371428718d2a3f27a0d231afe77a2620ac6312f80aaa28"; +const AFTER_HASH: &str = "ec956dcafb886f14315570bf3981d44aa12c561716abb46eed8b067aaa1f6bdf"; // --------------------------------------------------------------------------- // Helpers 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 597266ee2..62ba1728d 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 @@ -1541,3 +1541,281 @@ async fn gem_hosted_default_cwd_keeps_project_local_remedy() { ); } } + +/// #1109: `deployment` (local config, env or global config) stops Bundler +/// using system gems without an explicit `path`: it installs into +/// `vendor/bundle`. On a fresh checkout that store doesn't exist yet, but +/// `bundle install` still fetches into it and never reuses the `gem env` +/// copy, so it is not stale: no warning, and the same run's `--vex` +/// attests the purl. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn gem_hosted_deployment_ignores_system_home_copy() { + let server = MockServer::start().await; + mount_api(&server, None).await; + let cases: &[(&str, Option<&str>, &[(&str, &str)])] = &[ + ("local deployment", Some("BUNDLE_DEPLOYMENT: \"true\""), &[]), + ("env deployment", None, &[("BUNDLE_DEPLOYMENT", "true")]), + ( + "global deployment", + None, + &[("BUNDLE_USER_CONFIG", "../user-bundle-config")], + ), + ]; + for (case, local, extra) in cases { + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + write_manifest_pair(&proj); + if let Some(line) = local { + std::fs::create_dir_all(proj.join(".bundle")).unwrap(); + std::fs::write( + proj.join(".bundle").join("config"), + format!("---\n{line}\n"), + ) + .unwrap(); + } + std::fs::write( + tmp.path().join("user-bundle-config"), + "---\nBUNDLE_DEPLOYMENT: \"true\"\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(), + "{case}: bundler never reuses {}: {env}", + system_copy.display() + ); + assert_eq!( + code, 0, + "{case}: 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), "{case}: purl not attested:\n{doc}"); + } +} + +/// #1109 controls: a falsy `deployment`, or a higher tier that decides +/// the path with system gems on (`path.system`, or a falsy +/// `disable_shared_gems`), leaves Bundler on the system gems, so the stale +/// `gem env` copy still warns and stays out of the VEX. So do the +/// `.bundle`-default flags: Bundler 2.x honors only +/// `default_install_uses_path` and Bundler 4.x only `simulate_version 5`, +/// and a scan can't tell which Bundler runs, so it keeps judging the +/// system home rather than risk skipping a copy Bundler loads. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn gem_hosted_system_gems_settings_still_flag_system_home_copy() { + let server = MockServer::start().await; + mount_api(&server, None).await; + let cases: &[(&str, Option<&str>, &[(&str, &str)])] = &[ + ( + "local deployment false over env deployment", + Some("BUNDLE_DEPLOYMENT: \"false\""), + &[("BUNDLE_DEPLOYMENT", "true")], + ), + ( + "local path.system over env deployment", + Some("BUNDLE_PATH__SYSTEM: \"true\""), + &[("BUNDLE_DEPLOYMENT", "true")], + ), + ( + "local disable_shared_gems false over env deployment", + Some("BUNDLE_DISABLE_SHARED_GEMS: \"false\""), + &[("BUNDLE_DEPLOYMENT", "true")], + ), + ( + "local simulate_version 5", + Some("BUNDLE_SIMULATE_VERSION: \"5\""), + &[], + ), + ( + "local default_install_uses_path", + Some("BUNDLE_DEFAULT_INSTALL_USES_PATH: \"true\""), + &[], + ), + ]; + for (case, local, extra) in cases { + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + write_manifest_pair(&proj); + if let Some(line) = local { + std::fs::create_dir_all(proj.join(".bundle")).unwrap(); + std::fs::write( + proj.join(".bundle").join("config"), + format!("---\n{line}\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); + let warnings = stale_warnings(&env); + assert_eq!(warnings.len(), 1, "{case}: {env}"); + assert!( + warnings[0].contains(&system_copy.display().to_string()), + "{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}: stderr:\n{stderr}"); + } +} + +/// The lock `bundle install` writes once the hosted pin is installed +/// (bundler >= 2.2: a separate patch-registry `GEM` section). +fn converged_lock(api: &str) -> String { + let index_url = format!("{api}/patch-registry/gem/{TOKEN}/{UUID}/"); + format!( + "GEM\n remote: {index_url}\n specs:\n {DEP} ({DEP_VERSION})\n\n\ + GEM\n remote: https://rubygems.org/\n specs:\n\n\ + PLATFORMS\n ruby\n\nDEPENDENCIES\n {DEP} (= {DEP_VERSION})!\n\n\ + BUNDLED WITH\n 2.6.9\n" + ) +} + +/// #1098 (and #1109 for standalone `vex`): when Bundler doesn't use system +/// gems for the project, standalone `vex` must not judge the unused, +/// unpatched copy in the `gem env` home. Each case scans, converges the +/// lock the way `bundle install` would, then runs a manifest-less `vex` +/// with the same fake `gem` on `PATH`: +/// +/// - a fresh checkout under an explicit `path` (env, local or global +/// config) or `deployment`, nothing installed yet: attested from the +/// lock; +/// - an installed `BUNDLE_PATH: gems` holding the PATCHED copy: verified. +/// +/// The control, where Bundler does use system gems, still refuses the +/// unpatched system copy with `not_applied`. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn gem_hosted_standalone_vex_ignores_unused_system_home_copy() { + use vex_e2e_common::{ + assert_attested, assert_not_attested, run_vex, strip_ledgers, strip_manifest, Marker, + VexRun, + }; + let server = MockServer::start().await; + mount_api(&server, None).await; + let bin = vex_e2e_common::binary(); + let vulns: &[(&str, &[&str])] = &[(GHSA, &["CVE-2026-4444"])]; + + enum Config { + None, + Local(&'static str), + Env(&'static str, &'static str), + Global(&'static str), + } + let cases: &[(&str, Config, bool, bool)] = &[ + // (case, config, install the patched copy under `gems/`, attests) + ( + "fresh, local path", + Config::Local("BUNDLE_PATH: \"vendor/bundle\""), + false, + true, + ), + ( + "fresh, env path", + Config::Env("BUNDLE_PATH", "vendor/bundle"), + false, + true, + ), + ( + "fresh, global path", + Config::Global("BUNDLE_PATH: \"vendor/bundle\""), + false, + true, + ), + ( + "fresh, local deployment", + Config::Local("BUNDLE_DEPLOYMENT: \"true\""), + false, + true, + ), + ( + "installed, local path gems", + Config::Local("BUNDLE_PATH: \"gems\""), + true, + true, + ), + ("control, system gems", Config::None, false, false), + ]; + for (case, config, install, attests) in cases { + 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"); + stage_system_home_copy(&tmp.path().join("system-home"), &bin_dir); + let global_config = tmp.path().join("user-bundle-config"); + let mut envs: Vec<(String, std::ffi::OsString)> = + vec![("PATH".into(), bin_dir.clone().into_os_string())]; + match config { + Config::None => {} + Config::Local(line) => { + std::fs::create_dir_all(proj.join(".bundle")).unwrap(); + std::fs::write( + proj.join(".bundle").join("config"), + format!("---\n{line}\n"), + ) + .unwrap(); + } + Config::Env(k, v) => envs.push(((*k).into(), (*v).into())), + Config::Global(line) => { + std::fs::write(&global_config, format!("---\n{line}\n")).unwrap(); + envs.push(("BUNDLE_USER_CONFIG".into(), global_config.clone().into())); + } + } + let scan_env: Vec<(&str, &str)> = envs + .iter() + .filter(|(k, _)| k != "PATH") + .map(|(k, v)| (k.as_str(), v.to_str().unwrap())) + .collect(); + let (code, env, stderr, _) = + hosted_vex_scan_with_gem_on_path(&proj, &server.uri(), &bin_dir, &scan_env); + assert_eq!( + code == 0, + *attests, + "{case}: hosted scan --vex.\nenvelope: {env}\nstderr:\n{stderr}" + ); + strip_manifest(&proj); + strip_ledgers(&proj); + std::fs::write(proj.join("Gemfile.lock"), converged_lock(&server.uri())).unwrap(); + if *install { + let gem_dir = proj + .join("gems") + .join("ruby") + .join("3.3.0") + .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"), PATCHED_LIB).unwrap(); + } + + let run = VexRun { + api_url: Some(server.uri()), + api_token: Some("fake".into()), + org: Some(ORG.into()), + patch_server_url: Some(server.uri()), + product: Some("pkg:gem/app@1.0.0".into()), + envs, + ..VexRun::default() + }; + let out = run_vex(&bin, &proj, &run); + if *attests { + assert_eq!(out.code, Some(0), "{case}: {out}"); + assert_attested(out.doc(), PURL, UUID, Marker::Redirected, vulns); + } else { + assert_eq!(out.code, Some(1), "{case}: {out}"); + assert_not_attested(&out.envelope, PURL, "not_applied"); + } + } +} diff --git a/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs b/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs index 783e7337a..e70b3511d 100644 --- a/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs +++ b/crates/socket-patch-cli/tests/e2e_safety_pnpm.rs @@ -11,7 +11,7 @@ //! view and the store entry byte-identical. //! //! Fixture: minimist@1.2.2 + its Socket patch (UUID -//! `80630680-4da6-45f9-bba8-b888e0ffd58c`, CVE-2021-44906) — same +//! `642d7f02-ebc1-4ab0-99e2-07f5dd8463cb`, CVE-2021-44906) — same //! pair `e2e_npm.rs` uses, so the BEFORE/AFTER hashes are known. //! //! Network: yes (pnpm install + socket-patch get). Toolchain: pnpm. @@ -24,12 +24,12 @@ mod common; use common::{assert_run_ok, git_sha256_file, has_command, pnpm_run, write_package_json}; -const NPM_UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; +const NPM_UUID: &str = "642d7f02-ebc1-4ab0-99e2-07f5dd8463cb"; /// Git-SHA-256 of the *unpatched* `index.js` shipped with minimist 1.2.2. const BEFORE_HASH: &str = "311f1e893e6eac502693fad8617dcf5353a043ccc0f7b4ba9fe385e838b67a10"; /// Git-SHA-256 of the *patched* `index.js` after the security fix. -const AFTER_HASH: &str = "043f04d19e884aa5f8371428718d2a3f27a0d231afe77a2620ac6312f80aaa28"; +const AFTER_HASH: &str = "ec956dcafb886f14315570bf3981d44aa12c561716abb46eed8b067aaa1f6bdf"; // ── Setup helpers ───────────────────────────────────────────────────── diff --git a/crates/socket-patch-cli/tests/e2e_vendored_production.rs b/crates/socket-patch-cli/tests/e2e_vendored_production.rs index 51a34a20f..53f2f2d9c 100644 --- a/crates/socket-patch-cli/tests/e2e_vendored_production.rs +++ b/crates/socket-patch-cli/tests/e2e_vendored_production.rs @@ -49,7 +49,7 @@ //! //! | Ecosystem | PURL | Patch UUID | Marker in the patched bytes | //! |-----------|------|------------|-----------------------------| -//! | npm | `pkg:npm/minimist@1.2.2` | `80630680-4da6-45f9-bba8-b888e0ffd58c` | `Socket Community Patch` header | +//! | npm | `pkg:npm/minimist@1.2.2` | `642d7f02-ebc1-4ab0-99e2-07f5dd8463cb` | `Socket Community Patch` header | //! | PyPI | `pkg:pypi/urllib3@1.26.18` | *any of three* (see [`PYPI_UUIDS`]) | `Socket Community Patch` header | //! | gem | `pkg:gem/activestorage@6.0.3` | *any of* [`GEM_PATCHES`] | `Socket Community Patch` header | //! @@ -137,7 +137,7 @@ const PROXY: &str = "https://patches-api.socket.dev"; const NPM_PURL: &str = "pkg:npm/minimist@1.2.2"; const NPM_NAME: &str = "minimist"; const NPM_VERSION: &str = "1.2.2"; -const NPM_UUID: &str = "80630680-4da6-45f9-bba8-b888e0ffd58c"; +const NPM_UUID: &str = "642d7f02-ebc1-4ab0-99e2-07f5dd8463cb"; const PYPI_PURL: &str = "pkg:pypi/urllib3@1.26.18"; const PYPI_NAME: &str = "urllib3"; diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index ae366ad86..31f8382e3 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -635,9 +635,8 @@ impl RubyCrawler { /// /// Unlike [`Self::get_gem_paths`] (apply's write targets, which keep /// the `gem env` homes for default gems), the `gem env` homes count - /// here only when Bundler uses system gems: no deployment store under - /// the default `vendor/bundle` and no explicit install `path` - /// ([`bundler_sets_explicit_path`]). The refused out-of-tree + /// here only when Bundler uses system gems + /// ([`Self::bundler_uses_system_gems`]). 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. @@ -651,28 +650,7 @@ 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 - && 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 { + let system_homes = if Self::bundler_uses_system_gems(&options.cwd, &discovery).await { Self::gem_env_gems_dirs().await } else { Vec::new() @@ -685,6 +663,57 @@ impl RubyCrawler { ) } + /// The `gem env` homes Bundler never loads this project's gems from, + /// for read-only verifiers (`vex`): [`Self::get_gem_paths`] keeps them + /// as apply write targets for default gems, but under an explicit or + /// deployment `path` Bundler fetches every other gem into that path, so + /// an unpatched copy there is not one the project runs (#1098). Empty + /// in global / `--global-prefix` mode and whenever Bundler uses system + /// gems. A home that is also one of the project's Bundler stores is + /// never listed. + pub async fn bundler_unused_system_gem_homes(&self, options: &CrawlerOptions) -> Vec { + if options.global || options.global_prefix.is_some() { + return Vec::new(); + } + let discovery = Self::discover_bundle_stores(&options.cwd).await; + if Self::bundler_uses_system_gems(&options.cwd, &discovery).await { + return Vec::new(); + } + Self::gem_env_gems_dirs() + .await + .into_iter() + .filter(|home| !discovery.stores.contains(home)) + .collect() + } + + /// Whether `bundle install` installs into, and loads from, the system + /// gem homes for the project at `cwd`: a Ruby project with no + /// deployment store under the default `vendor/bundle` and no explicit + /// install `path` ([`bundler_sets_explicit_path`], which also counts + /// `deployment`). The one answer both the stale-install guard + /// ([`Self::bundler_install_homes`]) and `vex` + /// ([`Self::bundler_unused_system_gem_homes`]) use. + async fn bundler_uses_system_gems(cwd: &Path, discovery: &BundleStoreDiscovery) -> bool { + if discovery.default_root_has_stores || !Self::has_bundler_manifest(cwd).await { + return false; + } + let ignore_config = bundler_ignores_config(); + !bundler_sets_explicit_path(BundlerPathTiers { + local: read_app_config( + cwd, + std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), + ignore_config, + ) + .await, + env: BundlerPathSettings::from_ambient_env(), + global: read_global_config( + ambient_bundler_global_config_file(cwd).as_deref(), + ignore_config, + ) + .await, + }) + } + /// The installed-gem `gems/` dirs under one bundler install root, in /// both layouts bundler produces: /// @@ -1146,14 +1175,15 @@ 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). +/// One Bundler settings tier's `path`, `path.system`, +/// `disable_shared_gems` and `deployment` 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, + deployment: Option, } impl BundlerPathSettings { @@ -1165,6 +1195,7 @@ impl BundlerPathSettings { text, "BUNDLE_DISABLE_SHARED_GEMS", ), + deployment: bundle_config_setting_including_empty(text, "BUNDLE_DEPLOYMENT"), } } @@ -1172,14 +1203,27 @@ impl BundlerPathSettings { path: Option<&OsStr>, path_system: Option<&OsStr>, disable_shared_gems: Option<&OsStr>, + deployment: 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), + deployment: text(deployment), } } + + /// The ambient environment tier (`BUNDLE_PATH`, `BUNDLE_PATH__SYSTEM`, + /// `BUNDLE_DISABLE_SHARED_GEMS`, `BUNDLE_DEPLOYMENT`). + fn from_ambient_env() -> Self { + Self::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(), + std::env::var_os("BUNDLE_DEPLOYMENT").as_deref(), + ) + } } /// The settings tiers Bundler's `Settings#path` reads, highest first: the @@ -1195,15 +1239,21 @@ pub(crate) struct BundlerPathTiers { /// gems, following `Bundler::Settings#path`: the first tier (local, env, /// global) that sets `path`, `path.system` or `disable_shared_gems` decides /// alone, and it uses system gems when `path.system` is truthy or -/// `disable_shared_gems` is falsy ([`bundler_truthy`]). Bundler never reuses a `gem env` copy -/// of a non-default gem under an explicit path (`use_system_gems?` is -/// false). +/// `disable_shared_gems` is falsy ([`bundler_truthy`]). When no tier sets +/// any of them, a truthy `deployment` (the first tier that sets it wins) +/// makes `vendor/bundle` the explicit path (#1109). 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. +/// Bundler may load. For the same reason the `.bundle` default that +/// `default_install_uses_path` (honored by Bundler 2.x only) and +/// `simulate_version 5` (Bundler 4.x only) select is not modeled here: +/// which one applies depends on the Bundler that runs, which a scan can't +/// read. pub(crate) fn bundler_sets_explicit_path(tiers: BundlerPathTiers) -> bool { - let settings = [ + let settings: Vec = [ tiers .local .as_deref() @@ -1213,8 +1263,11 @@ pub(crate) fn bundler_sets_explicit_path(tiers: BundlerPathTiers) -> bool { .global .as_deref() .map(BundlerPathSettings::from_config_text), - ]; - for tier in settings.into_iter().flatten() { + ] + .into_iter() + .flatten() + .collect(); + for tier in &settings { if tier.path.is_none() && tier.path_system.is_none() && tier.disable_shared_gems.is_none() { continue; } @@ -1224,9 +1277,31 @@ pub(crate) fn bundler_sets_explicit_path(tiers: BundlerPathTiers) -> bool { .disable_shared_gems .as_deref() .is_some_and(|v| !bundler_truthy(v)); - return !system && tier.path.is_some_and(|p| !p.is_empty()); + return !system && tier.path.as_deref().is_some_and(|p| !p.is_empty()); } - false + // `path = "vendor/bundle" if self[:deployment]`, after the tier loop. + settings + .iter() + .find_map(|tier| tier.deployment.as_deref()) + .is_some_and(bundler_truthy) +} + +/// Whether the installed gem dir `copy` (`/gems/-`) +/// is a default gem: its spec sits in `/specifications/default/`. +/// Bundler loads a default gem from the system home even under an explicit +/// `path`, so a verifier that drops the unused `gem env` homes +/// ([`RubyCrawler::bundler_unused_system_gem_homes`]) still judges it. +pub fn is_default_gem_copy(copy: &Path) -> bool { + let (Some(dir_name), Some(home)) = (copy.file_name(), copy.parent().and_then(Path::parent)) + else { + return false; + }; + let mut spec = dir_name.to_os_string(); + spec.push(".gemspec"); + home.join("specifications") + .join("default") + .join(spec) + .is_file() } /// One gem home from [`RubyCrawler::bundler_install_homes`]. @@ -4096,6 +4171,7 @@ mod tests { path.map(OsStr::new), system.map(OsStr::new), disable.map(OsStr::new), + None, ) }; let tiers = |local: Option<&str>, env: BundlerPathSettings, global: Option<&str>| { @@ -4173,6 +4249,92 @@ mod tests { ); } + /// #1098: a `gem env` copy whose spec sits in + /// `specifications/default/` is a default gem, which Bundler loads from + /// the system home under any path; a regular spec is not. + #[test] + fn is_default_gem_copy_reads_the_default_specifications_dir() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path(); + let json = home.join("gems").join("json-2.7.2"); + let rack = home.join("gems").join("rack-3.1.8"); + std::fs::create_dir_all(&json).unwrap(); + std::fs::create_dir_all(&rack).unwrap(); + std::fs::create_dir_all(home.join("specifications").join("default")).unwrap(); + std::fs::write( + home.join("specifications") + .join("default") + .join("json-2.7.2.gemspec"), + "", + ) + .unwrap(); + std::fs::write(home.join("specifications").join("rack-3.1.8.gemspec"), "").unwrap(); + assert!(is_default_gem_copy(&json)); + assert!(!is_default_gem_copy(&rack)); + assert!(!is_default_gem_copy(Path::new("json-2.7.2"))); + } + + /// #1109: with no tier setting `path`, `path.system` or + /// `disable_shared_gems`, a truthy `deployment` makes `vendor/bundle` + /// the explicit path (`Settings#path`, Bundler 2.5 and 4.0 alike). The + /// first tier that sets `deployment` decides, through `to_bool`, and + /// any tier that decides the path outranks it. The version-dependent + /// `.bundle` flags are left to the system-gems answer. + #[test] + fn bundler_sets_explicit_path_counts_deployment() { + let env = |deployment: Option<&str>, system: Option<&str>| { + BundlerPathSettings::from_env( + None, + system.map(OsStr::new), + None, + deployment.map(OsStr::new), + ) + }; + let tiers = |local: Option<&str>, env: BundlerPathSettings, global: Option<&str>| { + bundler_sets_explicit_path(BundlerPathTiers { + local: local.map(str::to_string), + env, + global: global.map(str::to_string), + }) + }; + let local_deploy = "---\nBUNDLE_DEPLOYMENT: \"true\"\n"; + + assert!(tiers(Some(local_deploy), env(None, None), None), "local"); + assert!(tiers(None, env(Some("true"), None), None), "env"); + assert!(tiers(None, env(None, None), Some(local_deploy)), "global"); + assert!(tiers(None, env(Some("1"), None), None), "to_bool"); + assert!(!tiers(None, env(Some("false"), None), None), "falsy"); + assert!( + !tiers( + Some("---\nBUNDLE_DEPLOYMENT: \"false\"\n"), + env(Some("true"), None), + None + ), + "a local falsy deployment shadows the env one" + ); + assert!( + !tiers(Some(local_deploy), env(None, Some("true")), None), + "an env path.system decides the path before deployment is read" + ); + assert!( + !tiers( + Some("---\nBUNDLE_DISABLE_SHARED_GEMS: \"false\"\n"), + env(Some("true"), None), + None + ), + "a falsy disable_shared_gems decides before deployment is read" + ); + for flag in [ + "BUNDLE_SIMULATE_VERSION: \"5\"", + "BUNDLE_DEFAULT_INSTALL_USES_PATH: \"true\"", + ] { + assert!( + !tiers(Some(&format!("---\n{flag}\n")), env(None, None), None), + "{flag} depends on the Bundler version: keep the system homes" + ); + } + } + /// #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` diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs index 944435d03..8c2a0d793 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/mod.rs @@ -917,7 +917,10 @@ mod tests { fn bun_lock_remedies_name_the_forced_reinstall() { for file in ["bun.lockb", "bun.lock", "packages/app/bun.lockb"] { let remedy = checkout_remedy(&[file.to_string()]); - assert!(remedy.contains(&format!("`git checkout -- {file}`")), "{remedy}"); + assert!( + remedy.contains(&format!("`git checkout -- {file}`")), + "{remedy}" + ); assert!(remedy.ends_with( ", then run `bun install --force` (a plain `bun install` keeps the patched copy)" ), "{remedy}"); diff --git a/docs/testing/bun-compatibility.md b/docs/testing/bun-compatibility.md index 26ef8e5d5..a65b4b96d 100644 --- a/docs/testing/bun-compatibility.md +++ b/docs/testing/bun-compatibility.md @@ -5,7 +5,7 @@ projects using text `bun.lock` or native binary `bun.lockb`. Real-Bun evidence b - **The native matrix** — `scripts/backtest-bun.py` runs real Bun releases against the public free Socket patch for `minimist@1.2.2` - (`80630680-4da6-45f9-bba8-b888e0ffd58c`) with the production CLI and patch + (`642d7f02-ebc1-4ab0-99e2-07f5dd8463cb`) with the production CLI and patch service, without a token or substitute service, and checks the INSTALLED bytes, lock stability, digest rejection and rollback on Linux, macOS and Windows ([workflow](../../.github/workflows/bun-compatibility.yml)). diff --git a/scripts/backtest-bun.py b/scripts/backtest-bun.py index c7f10aa77..71983e544 100644 --- a/scripts/backtest-bun.py +++ b/scripts/backtest-bun.py @@ -117,7 +117,7 @@ # former `vendored-detached` leg collapsed into `vendored`: same footprint. MODES = ['hosted', 'vendored'] PURL = 'pkg:npm/minimist@1.2.2' -UUID = '80630680-4da6-45f9-bba8-b888e0ffd58c' +UUID = '642d7f02-ebc1-4ab0-99e2-07f5dd8463cb' # The registry slot bun writes for a non-default registry: the full tarball URL. REGISTRY_SLOT = 'https://registry.npmjs.org/minimist/-/minimist-1.2.2.tgz' LOCAL_TUPLE_SPEC = f'minimist@.socket/vendor/npm/{UUID}/minimist-1.2.2.tgz' diff --git a/scripts/backtest-vlt.py b/scripts/backtest-vlt.py index 18ed56f9d..fb8533fa2 100644 --- a/scripts/backtest-vlt.py +++ b/scripts/backtest-vlt.py @@ -88,7 +88,7 @@ VERSIONS = ['0.0.0-16', '0.0.0-32', '1.0.0-rc.14', '1.0.0-rc.32', '1.0.4', '1.0.10', '1.2.0'] MODES = ['hosted', 'vendored', 'agent'] PURL = 'pkg:npm/minimist@1.2.2' -UUID = '80630680-4da6-45f9-bba8-b888e0ffd58c' +UUID = '642d7f02-ebc1-4ab0-99e2-07f5dd8463cb' NAME = 'minimist' VERSION = '1.2.2' TARGET = f'{NAME}@{VERSION}' @@ -1069,7 +1069,7 @@ def holds(self, root, lock_text, side): ok = True for copy_dir in self.copies(root, lock_text): for key, hashes in self.record['files'].items(): - path = copy_dir / key.split('/', 1)[1] + path = copy_dir / key.removeprefix('package/') digest = git_hash(path.read_bytes()) if path.is_file() else None details[str(path.relative_to(root))] = digest ok = ok and digest == hashes.get(f'{side}Hash')