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