From 259d99660569e2ca08eb6c2000ddc9610d7e5dc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 05:26:19 +0000 Subject: [PATCH 1/4] Start fix for #483, #507 Assisted-by: Claude Code:claude-opus-5-5 From dc209a59dc9c49318597e1c99c1698e3f140dfb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 05:48:26 +0000 Subject: [PATCH 2/4] Read Bundler settings in Bundler's priority When BUNDLE_GEMFILE was set both in .bundle/config and in the environment, socket-patch followed the environment. Bundler does the reverse: local app config outranks ENV. A dual-boot project with `gemfile Gemfile.next` committed and BUNDLE_GEMFILE=Gemfile exported got its Gemfile rewired and attested while bundler installed Gemfile.next unpatched. The app config value now wins, unless the environment names a manifest in another directory (that moves bundler's root, so the project's config is never read). Add one app-config reader shared by every Bundler key, and a resolver for bundler's cache dir (cache_path / BUNDLE_CACHE_PATH, default vendor/cache) in the same priority. Refs #507, #483 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_redirect_gem_build.rs | 56 ++++++- .../src/crawlers/ruby_crawler.rs | 155 ++++++++++++++++++ .../src/formats/gem/manifest.rs | 147 ++++++++++++----- docs/ecosystems.md | 2 +- 4 files changed, 314 insertions(+), 46 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs index fe874c013..4a2f0e6fd 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -155,6 +155,11 @@ fn binary() -> PathBuf { /// (a developer's `SOCKET_DRY_RUN=1` must not steer the assertions) and /// `VIRTUAL_ENV` (crawler discovery input) removed. fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { + run_socket_env(cwd, args, &[]) +} + +/// [`run_socket`] with extra environment on top of the scrubbed surface. +fn run_socket_env(cwd: &Path, args: &[&str], envs: &[(&str, &str)]) -> (i32, String, String) { let mut cmd = Command::new(binary()); cmd.args(args).current_dir(cwd); for (k, _) in std::env::vars_os() { @@ -163,6 +168,9 @@ fn run_socket(cwd: &Path, args: &[&str]) -> (i32, String, String) { } } cmd.env_remove("VIRTUAL_ENV"); + for (k, v) in envs { + cmd.env(k, v); + } let out = cmd.output().expect("failed to run socket-patch binary"); ( out.status.code().unwrap_or(-1), @@ -405,6 +413,11 @@ enum Driver { /// `Gemfile.next`, so the run must redirect nothing and attest nothing. /// The fixture asserts that contract itself and yields `None`. ScanVexDualBoot, + /// [`Driver::ScanVexDualBoot`] with `BUNDLE_GEMFILE=Gemfile` exported to + /// socket-patch too (#507): bundler's local app config outranks the + /// environment, so bundler still loads `Gemfile.next` and the run must + /// still redirect and attest nothing. + ScanVexDualBootEnvGemfile, } impl Driver { @@ -413,6 +426,9 @@ impl Driver { Driver::ScanVex => "scan --mode hosted", Driver::GetUuid => "get --mode hosted", Driver::ScanVexDualBoot => "scan --mode hosted (BUNDLE_GEMFILE=Gemfile.next)", + Driver::ScanVexDualBootEnvGemfile => { + "scan --mode hosted (config Gemfile.next, env BUNDLE_GEMFILE=Gemfile)" + } } } } @@ -760,7 +776,11 @@ async fn redirect_scanned_project( // --vex (get has none), get's envelope with the nested `redirect`. let api = server.uri(); let proj_str = proj.to_str().expect("utf8 tmp path"); - if driver == Driver::ScanVexDualBoot { + let dual_boot = matches!( + driver, + Driver::ScanVexDualBoot | Driver::ScanVexDualBootEnvGemfile + ); + if dual_boot { // The next-Rails dual boot: a `Gemfile.next` pair that bundler loads // through the committed `.bundle/config`. std::fs::copy(proj.join(gemfile_name), proj.join("Gemfile.next")).unwrap(); @@ -775,7 +795,7 @@ async fn redirect_scanned_project( ); } let argv: Vec<&str> = match driver { - Driver::ScanVex | Driver::ScanVexDualBoot => vec![ + Driver::ScanVex | Driver::ScanVexDualBoot | Driver::ScanVexDualBootEnvGemfile => vec![ "scan", "--mode", "hosted", @@ -811,8 +831,12 @@ async fn redirect_scanned_project( "fake", ], }; - let (code, stdout, stderr) = run_socket(&proj, &argv); - if driver == Driver::ScanVexDualBoot { + let socket_env: &[(&str, &str)] = match driver { + Driver::ScanVexDualBootEnvGemfile => &[("BUNDLE_GEMFILE", "Gemfile")], + _ => &[], + }; + let (code, stdout, stderr) = run_socket_env(&proj, &argv, socket_env); + if dual_boot { let env: serde_json::Value = serde_json::from_str(&stdout) .unwrap_or_else(|e| panic!("not JSON: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}")); // `--vex` with nothing to attest is an error: the run must not @@ -898,7 +922,9 @@ async fn redirect_scanned_project( "in-run hosted VEX is attested from this run's fetched record, not hash-verified: {env}" ); } - Driver::ScanVexDualBoot => unreachable!("asserted and returned above"), + Driver::ScanVexDualBoot | Driver::ScanVexDualBootEnvGemfile => { + unreachable!("asserted and returned above") + } Driver::GetUuid => { // get's hosted envelope (CLI_CONTRACT.md "get --mode and // installed narrowing"): `found` counts the resolved patch; @@ -1549,6 +1575,26 @@ async fn gem_hosted_bundle_gemfile_dual_boot_redirects_nothing() { assert!(fx.is_none(), "the dual-boot driver asserts in place"); } +/// #507: the same dual boot with `BUNDLE_GEMFILE=Gemfile` exported. Bundler +/// ranks the committed `.bundle/config` above the environment (it still +/// loads `Gemfile.next`), so socket-patch must not follow the env value and +/// wire the `Gemfile` bundler ignores. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17; CHECKSUMS arm >= 2.6); \ + the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"] +async fn gem_hosted_bundle_gemfile_config_outranks_env_redirects_nothing() { + let fx = redirect_scanned_project( + "dual-boot-env", + Spelling::Gemfile, + false, + true, + None, + Driver::ScanVexDualBootEnvGemfile, + ) + .await; + assert!(fx.is_none(), "the dual-boot driver asserts in place"); +} + /// The compact-index DEPENDENCY contract, pinned from the red side: a patch /// registry whose `/info` omits the gem's runtime deps (production's /// HISTORICAL behavior until the 2026-08-18 republish fixed the served index) diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index d8c4faf52..da9d6d781 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -989,6 +989,48 @@ pub(crate) fn bundler_app_config_dir(root: &Path, env_value: Option<&OsStr>) -> } } +/// Bundler's app cache dir for `root` — where `bundle cache` writes and +/// `bundle install` installs from in preference to fetching +/// (`Bundler.app_cache`: `/`, default `vendor/cache`). +/// Reads the ambient `BUNDLE_CACHE_PATH` / `BUNDLE_APP_CONFIG`. +pub async fn bundler_app_cache_dir(root: &Path) -> PathBuf { + bundler_app_cache_dir_with_env( + root, + std::env::var_os("BUNDLE_CACHE_PATH").as_deref(), + std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), + ) + .await +} + +/// [`bundler_app_cache_dir`] with the environment passed explicitly +/// (hermetic tests). The `cache_path` setting follows `Bundler::Settings` +/// priority like every other key: the app config file's +/// `BUNDLE_CACHE_PATH:` (`bundle config set --local cache_path …`) first, +/// then the `BUNDLE_CACHE_PATH` environment variable. A relative value is +/// read against the project root; an absolute one stands alone (bundler +/// `Pathname#join`s it onto the root). The result is only ever READ (a +/// committed archive is hashed and named in a warning), so unlike a +/// config-sourced `BUNDLE_PATH` it needs no containment: a value that +/// points outside the project names exactly the file bundler installs from. +pub async fn bundler_app_cache_dir_with_env( + root: &Path, + cache_env: Option<&OsStr>, + app_config_env: Option<&OsStr>, +) -> PathBuf { + let config = bundler_app_config_dir(root, app_config_env).join("config"); + let configured = crate::utils::fs::read_regular_to_string(&config) + .await + .ok() + .and_then(|text| bundle_config_setting(&text, "BUNDLE_CACHE_PATH")) + .map(PathBuf::from) + .or_else(|| cache_env.filter(|v| !v.is_empty()).map(PathBuf::from)); + match configured { + // Component-wise, so `vendor/gems` uses the native separator. + Some(value) => root.join(normalize_lexically(&value).unwrap_or(value)), + None => root.join("vendor").join("cache"), + } +} + /// Resolve a trusted (ENV-sourced) `BUNDLE_PATH` value against the project /// root. Bundler `File.expand_path`s the value: a leading `~` expands to /// the user's home, and a relative path resolves against the directory of @@ -1095,6 +1137,22 @@ fn parse_bundle_config_path(contents: &str) -> Option { } } +/// The effective `:` value of a bundler app config file (flat YAML +/// that bundler writes itself, `---\nBUNDLE_GEMFILE: "Gemfile.next"\n`): +/// the last entry for the exact key wins, quotes are unwrapped, and an +/// empty value counts as unset. The colon must follow the key directly, so +/// `BUNDLE_PATH__SYSTEM:` never matches `BUNDLE_PATH`. +pub(crate) fn bundle_config_setting(contents: &str, key: &str) -> Option { + let mut found = None; + for line in contents.lines() { + if let Some(rest) = line.strip_prefix(key).and_then(|r| r.strip_prefix(':')) { + let v = unquote_bundle_config_value(rest); + found = (!v.is_empty()).then(|| v.to_string()); + } + } + found +} + /// Unwrap one bundler app-config scalar: trim, then strip one matching /// pair of double or single quotes (bundler double-quotes what it writes). pub(crate) fn unquote_bundle_config_value(rest: &str) -> &str { @@ -1151,6 +1209,103 @@ mod tests { assert_eq!(m, crate::formats::gem::manifest::LoadedManifest::Default); } + /// #507: a committed `bundle config set --local gemfile Gemfile.next` + /// beats an exported `BUNDLE_GEMFILE=Gemfile`, as in bundler, so the run + /// refuses instead of wiring the `Gemfile` bundler ignores. + #[tokio::test] + async fn loaded_manifest_app_config_beats_the_environment() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".bundle")).unwrap(); + std::fs::write( + dir.path().join(".bundle/config"), + "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n", + ) + .unwrap(); + let m = bundler_loaded_manifest_with_env( + dir.path(), + Some(std::ffi::OsStr::new("Gemfile")), + None, + ) + .await; + assert_eq!( + m, + crate::formats::gem::manifest::LoadedManifest::Unsupported { + value: "Gemfile.next".into(), + by: crate::formats::gem::manifest::GemfileSetting::AppConfig, + } + ); + } + + /// #483: bundler's cache dir is the `cache_path` setting — the app + /// config value first, then `BUNDLE_CACHE_PATH`, else `vendor/cache`. + #[tokio::test] + async fn app_cache_dir_follows_bundler_settings_priority() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + let default = root.join("vendor").join("cache"); + assert_eq!( + bundler_app_cache_dir_with_env(root, None, None).await, + default + ); + // The environment alone moves it. + let env = std::ffi::OsStr::new("vendor/env-gems"); + assert_eq!( + bundler_app_cache_dir_with_env(root, Some(env), None).await, + root.join("vendor").join("env-gems") + ); + // An empty value is unset. + assert_eq!( + bundler_app_cache_dir_with_env(root, Some(std::ffi::OsStr::new("")), None).await, + default + ); + // `bundle config set --local cache_path vendor/gems` outranks it. + std::fs::create_dir(root.join(".bundle")).unwrap(); + std::fs::write( + root.join(".bundle/config"), + "---\nBUNDLE_PATH: \"vendor/bundle\"\nBUNDLE_CACHE_PATH: \"vendor/gems\"\n", + ) + .unwrap(); + let configured = root.join("vendor").join("gems"); + assert_eq!( + bundler_app_cache_dir_with_env(root, None, None).await, + configured + ); + assert_eq!( + bundler_app_cache_dir_with_env(root, Some(env), None).await, + configured + ); + // BUNDLE_APP_CONFIG moves the config file: the env value applies. + assert_eq!( + bundler_app_cache_dir_with_env( + root, + Some(env), + Some(std::ffi::OsStr::new("elsewhere")) + ) + .await, + root.join("vendor").join("env-gems") + ); + // An absolute value stands alone. + let abs = root.join("shared-cache"); + std::fs::write( + root.join(".bundle/config"), + format!("---\nBUNDLE_CACHE_PATH: \"{}\"\n", abs.display()), + ) + .unwrap(); + assert_eq!(bundler_app_cache_dir_with_env(root, None, None).await, abs); + } + + #[test] + fn bundle_config_setting_matches_the_exact_key() { + let text = "---\nBUNDLE_PATH__SYSTEM: \"true\"\nBUNDLE_PATH: 'vendor/bundle'\n\ + BUNDLE_CACHE_PATH: \"\"\n"; + assert_eq!( + bundle_config_setting(text, "BUNDLE_PATH"), + Some("vendor/bundle".into()) + ); + assert_eq!(bundle_config_setting(text, "BUNDLE_CACHE_PATH"), None); + assert_eq!(bundle_config_setting(text, "BUNDLE_GEMFILE"), None); + } + #[test] fn test_parse_gem_dir_name() { assert_eq!( diff --git a/crates/socket-patch-core/src/formats/gem/manifest.rs b/crates/socket-patch-core/src/formats/gem/manifest.rs index 12644f3a1..e697f3587 100644 --- a/crates/socket-patch-core/src/formats/gem/manifest.rs +++ b/crates/socket-patch-core/src/formats/gem/manifest.rs @@ -2,16 +2,19 @@ //! hosted rewriter's caller and the vendored backend, so neither can wire a //! file Bundler ignores. //! -//! Bundler's own order (`Bundler::SharedHelpers#default_gemfile` and the CLI's -//! `gemfile` setting): +//! Bundler's own order (`Bundler::Settings` priority — local app config +//! over ENV — read by `Bundler::CLI#initialize`, which re-exports the +//! winning `gemfile` setting into `BUNDLE_GEMFILE`): //! -//! 1. `BUNDLE_GEMFILE` from the environment (a relative value is read -//! against the project root: bundler expands it against the directory -//! `bundle` runs in, which is the project, not socket-patch's own -//! cwd when it runs with `--cwd`); -//! 2. `BUNDLE_GEMFILE:` in the app config file, `$BUNDLE_APP_CONFIG/config` +//! 1. `BUNDLE_GEMFILE:` in the app config file, `$BUNDLE_APP_CONFIG/config` //! else `/.bundle/config` (what `bundle config set --local gemfile //! Gemfile.next` writes; relative to the project root); +//! 2. `BUNDLE_GEMFILE` from the environment (a relative value is read +//! against the project root: bundler expands it against the directory +//! `bundle` runs in, which is the project, not socket-patch's own +//! cwd when it runs with `--cwd`). An environment value naming a file in +//! ANOTHER directory moves `Bundler.root` there, and bundler then reads +//! that root's app config, never this project's — so it decides alone; //! 3. otherwise `gems.rb` when present, else `Gemfile` (bundler >= 2; 1.x //! reads a `Gemfile` first, so callers treat a twin as ambiguous or //! follow the >= 2 order, as the hosted rewriter does). @@ -28,7 +31,7 @@ use std::ffi::OsStr; use std::path::{Path, PathBuf}; -use crate::crawlers::ruby_crawler::unquote_bundle_config_value; +use crate::crawlers::ruby_crawler::bundle_config_setting; use crate::utils::fs::normalize_lexically; /// Where a configured `BUNDLE_GEMFILE` came from. @@ -112,42 +115,47 @@ impl LoadedManifest { /// The `BUNDLE_GEMFILE:` value of a bundler app config file (flat YAML that /// bundler writes itself; an empty value counts as unset). pub fn config_gemfile(contents: &str) -> Option { - let mut found = None; - for line in contents.lines() { - if let Some(rest) = line.strip_prefix("BUNDLE_GEMFILE:") { - let v = unquote_bundle_config_value(rest); - found = (!v.is_empty()).then(|| v.to_string()); - } - } - found + bundle_config_setting(contents, "BUNDLE_GEMFILE") +} + +/// `value` resolved against `root` (an absolute value stands alone), made +/// absolute and lexically normalized; `None` when that is impossible. +fn resolve_against(root: &Path, value: &Path) -> Option { + let joined = if value.is_absolute() { + value.to_path_buf() + } else { + root.join(value) + }; + std::path::absolute(joined) + .ok() + .and_then(|p| normalize_lexically(&p)) } -/// Classify the configured `BUNDLE_GEMFILE` (environment first, then the -/// app config value) against `root`, which also anchors a relative value. +/// Classify the configured `BUNDLE_GEMFILE` against `root`, which also +/// anchors a relative value: the app config value first, then the +/// environment — unless the environment names a manifest outside `root`, +/// which moves bundler's root (and with it the app config bundler reads) +/// away from this project. See the module doc. pub fn classify( root: &Path, gemfile_env: Option<&OsStr>, config_value: Option<&str>, ) -> LoadedManifest { - let (value, by) = match gemfile_env.filter(|v| !v.is_empty()) { - Some(v) => (PathBuf::from(v), GemfileSetting::Env), - None => match config_value.filter(|v| !v.is_empty()) { - Some(v) => (PathBuf::from(v), GemfileSetting::AppConfig), - None => return LoadedManifest::Default, - }, + let env = gemfile_env.filter(|v| !v.is_empty()).map(PathBuf::from); + let config = config_value.filter(|v| !v.is_empty()).map(PathBuf::from); + let env_keeps_root = |env: &Path| { + let dir = resolve_against(root, env).and_then(|p| p.parent().map(Path::to_path_buf)); + dir.is_some() && dir == resolve_against(root, Path::new("")) }; - let display = value.display().to_string(); - let absolute = |p: &Path| { - std::path::absolute(p) - .ok() - .and_then(|p| normalize_lexically(&p)) - }; - let target = if value.is_absolute() { - absolute(&value) - } else { - absolute(&root.join(&value)) + let (value, by) = match (env, config) { + (Some(env), Some(config)) if env_keeps_root(&env) => (config, GemfileSetting::AppConfig), + (Some(env), _) => (env, GemfileSetting::Env), + (None, Some(config)) => (config, GemfileSetting::AppConfig), + (None, None) => return LoadedManifest::Default, }; - let root = absolute(root); + let display = value.display().to_string(); + let target = resolve_against(root, &value); + let root = resolve_against(root, Path::new("")); if let (Some(target), Some(root)) = (target, root) { for manifest in ["Gemfile", "gems.rb"] { if target == root.join(manifest) { @@ -206,13 +214,48 @@ mod tests { assert_eq!(m.pair(true), Some(("Gemfile", "Gemfile.lock"))); } - /// The environment wins over the app config, and a relative value is - /// read against the project root even when socket-patch runs elsewhere - /// with `--cwd` (Bugbot on #431: `BUNDLE_GEMFILE=Gemfile` must select - /// the project's Gemfile, not a file under the process cwd). + /// The app config wins over the environment, as in `Bundler::Settings` + /// (local config has a higher priority than ENV, and `Bundler::CLI` + /// re-exports the winning `gemfile` setting into `BUNDLE_GEMFILE`): + /// `bundle config set --local gemfile Gemfile.next` plus an exported + /// `BUNDLE_GEMFILE=Gemfile` makes bundler load `Gemfile.next` (#507). #[test] - fn env_wins_over_config_and_is_anchored_at_the_project_root() { + fn config_wins_over_env_like_bundler_settings() { let m = classify(&root(), Some(OsStr::new("Gemfile")), Some("Gemfile.next")); + assert_eq!( + m, + LoadedManifest::Unsupported { + value: "Gemfile.next".into(), + by: GemfileSetting::AppConfig + } + ); + // The remedy names the setting bundler actually uses. + let detail = m.unsupported_detail().unwrap(); + assert!( + detail.contains("bundle config unset --local gemfile"), + "{detail}" + ); + // Both naming supported spellings: the config's pair is wired. + let m = classify(&root(), Some(OsStr::new("gems.rb")), Some("Gemfile")); + assert_eq!( + m, + LoadedManifest::Configured { + manifest: "Gemfile", + by: GemfileSetting::AppConfig + } + ); + let m = classify(&root(), Some(OsStr::new("Gemfile")), Some("gems.rb")); + assert_eq!(m.pair(false), Some(("gems.rb", "gems.locked"))); + } + + /// The environment applies when the app config sets nothing, and a + /// relative value is read against the project root even when + /// socket-patch runs elsewhere with `--cwd` (Bugbot on #431: + /// `BUNDLE_GEMFILE=Gemfile` must select the project's Gemfile, not a + /// file under the process cwd). + #[test] + fn env_applies_without_config_and_is_anchored_at_the_project_root() { + let m = classify(&root(), Some(OsStr::new("Gemfile")), None); assert_eq!( m, LoadedManifest::Configured { @@ -230,6 +273,30 @@ mod tests { )); } + /// An environment `BUNDLE_GEMFILE` in ANOTHER directory moves bundler's + /// root there (`Bundler.root` is the Gemfile's directory), so bundler + /// reads that root's app config, never this project's: the project's + /// `.bundle/config` cannot win, and the run refuses on the env value. + #[test] + fn env_gemfile_in_another_directory_is_never_overridden_by_project_config() { + for env in ["../other/Gemfile", "sub/Gemfile", "/elsewhere/Gemfile"] { + let m = classify(&root(), Some(OsStr::new(env)), Some("Gemfile")); + assert_eq!( + m, + LoadedManifest::Unsupported { + value: env.into(), + by: GemfileSetting::Env + }, + "{env}" + ); + } + // An absolute env value naming the root's own directory is the + // same root: the config still wins. + let abs = root().join("Gemfile.next"); + let m = classify(&root(), Some(abs.as_os_str()), Some("Gemfile")); + assert_eq!(m.pair(false), Some(("Gemfile", "Gemfile.lock"))); + } + /// The refusal names the remedy for the knob that set it: unsetting the /// environment variable does nothing to a `.bundle/config` setting. #[test] diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 00263964f..9f98fe27c 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -17,7 +17,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | npm (`npm`) — pnpm / yarn / berry / bun / vlt | ✅ any install layout, vlt's `node_modules/.vlt` store included (every store copy, copy-on-write) | ✅ seven lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun text `bun.lock` lockfileVersion 0/1/2 and native binary `bun.lockb` revisions 1/2/3 (binary locks stay binary; text workspace vendoring requires lockfileVersion 2 — see [Bun compatibility](testing/bun-compatibility.md)), vlt `vlt-lock.json` lockfileVersion 0/1 (patched package directories for direct dependencies of the root or a workspace member; transitive targets refused — see [vlt notes](#npm-vlt-notes)). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml and legacy shrinkwrap.yaml (pnpm majors 1–12; block and flow resolutions), yarn classic, yarn berry, bun, vlt (`vlt-lock.json` without `lockfileVersion`, 0 or 1) — pnpm, berry, bun and vlt carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) and [vlt notes](#npm-vlt-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ in place | ✅ uv project/script locks, PEP 751 `pylock.toml` / `pylock..toml`, poetry, pdm, pipenv (Pipenv 2018 or later — every `Pipfile.lock` category is rewired, lock-only checkouts included; Pipenv 2023+ does not hash-check local wheels — `vendor_integrity_unverified`; a venv still holding the upstream release is reported as `pypi_pipenv_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)), and requirements.txt. Native uv vendoring requires uv ≥ 0.2.35 (the `[[package]]` lock grammar); hosted mode covers native `uv.lock` from uv 0.1.45 (the first release whose `uv lock` writes one) and requirements from uv 0.0.5; see [uv compatibility](testing/uv-compatibility.md). | ✅ requirements.txt including hash continuations, uv project/script locks, and PEP 751 locks. Version/source ambiguity is refused; see [uv compatibility](testing/uv-compatibility.md). Poetry 1.x and 2.x locks are supported; Poetry 0.x ignores URL sources and is refused. See [Poetry compatibility](testing/poetry-compatibility.md). Pipenv `Pipfile.lock` (pipfile-spec 6 — Pipenv 7 and later; `path` references for 7–11, `file` from 2018; lock-only checkouts and Pipenv's out-of-tree venv are discovered; a warm venv that Pipenv will not reinstall over warns `redirect_pypi_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)). `pdm.lock` is supported for the lock formats PDM 0.12–1.4 and 2.8.1+ write (`lock_version` 2 / 4.3–4.5.1); the identity-losing 3.1 / 4.0–4.2 formats (PDM 1.8–2.7) are refused. PDM 2.8.0 writes an indistinguishable `4.3` lock but shares that identity-loss bug, so a rewritten 2.8.0 lock crashes `pdm sync` — upgrade to ≥ 2.8.1. See [PDM compatibility](testing/pdm-compatibility.md). | | Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry in the root `Cargo.toml` (v5; per-version Socket keys; pre-v5 `.cargo/config*` wiring migrates on re-run) | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum); direct dependencies only — a crate another dependency also pulls in is refused, use `--mode vendored`; with no `Cargo.lock` the graph is unknown, so only a project whose sole dependency is the patched crate is redirected | -| RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` twin, which bundler ≥ 2 loads instead, or a `BUNDLE_GEMFILE`-configured manifest makes vendoring refuse with `gemfile_not_loaded` before any write) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`; `BUNDLE_GEMFILE` from the environment or `.bundle/config` is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | +| RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` twin, which bundler ≥ 2 loads instead, or a `BUNDLE_GEMFILE`-configured manifest makes vendoring refuse with `gemfile_not_loaded` before any write) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`; `BUNDLE_GEMFILE` from `.bundle/config` (which outranks the environment, as in bundler) or the environment is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | | Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [Go notes](#go-directory-replaces-and-gosum). Paid hosted patches are unsupported; `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) | ✅ in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ single-POM repository, suffixed Maven reactor repository, or Gradle 6.8+ same-GAV repository with settings wiring and SHA-256 checks; see [JVM vendoring](design/maven-vendoring.md) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) | | NuGet (`nuget`) | ✅ in-place patching deletes `.nupkg.metadata` and advises on the `.nupkg.sha512` tamper-evidence sidecar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed folder feed + `packageSourceMapping` + `packages.lock.json` contentHash pin | ✅ `nuget.config` source + source-mapping, `packages.lock.json` contentHash rewrite. See the locked-mode note in [Maven & NuGet caveats](#maven--nuget-caveats) | From 477aae9b35e696e0822a04da1cb2de846b453824 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 05:48:27 +0000 Subject: [PATCH 3/4] Check the configured bundle cache for stale gems The hosted gem stale-install guard only looked for committed archives in vendor/cache. With `bundle config set --local cache_path vendor/gems` (or BUNDLE_CACHE_PATH), a committed unpatched archive there gave no warning, the same run's VEX attested the gem, and bundle install then installed the unpatched bytes. Both guard flavors now use bundler's configured cache dir, so the stale archive warns, joins the delete-list remedy, and keeps the purl out of the in-run attestation. Fixes #483 Fixes #507 Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 7 ++ crates/socket-patch-cli/CLI_CONTRACT.md | 6 +- .../src/commands/scan/hosted.rs | 111 ++++++++++++++++-- .../tests/e2e_redirect_gem_stale_install.rs | 82 +++++++++++++ 4 files changed, 194 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b03e92f8d..839af1192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,13 @@ limits, and required install commands. twin or a `BUNDLE_GEMFILE` setting (environment or `.bundle/config`) no longer leads to an edit of an ignored `Gemfile` that reports success and attests an unpatched gem; unsupported layouts are refused before any write (#341, #390). +- Gem modes read Bundler settings in Bundler's own priority. A `BUNDLE_GEMFILE` + in `.bundle/config` now outranks the environment variable, so a dual-boot + project with an exported `BUNDLE_GEMFILE=Gemfile` is no longer wired through + the `Gemfile` Bundler ignores (#507). The hosted stale-install guard checks + the committed archive in Bundler's configured cache dir (`cache_path` / + `BUNDLE_CACHE_PATH`) instead of always `vendor/cache`, so a stale archive + there now warns and keeps the same run's VEX from attesting it (#483). - **npm dependencies installed from git, a URL or `file:` are no longer reported patched.** npm installs such a dependency from the dependent's spec (`github:user/repo`, `https://…/x.tgz`, `file:…`) and ignores the diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 72bffac7a..bb9363141 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -161,11 +161,11 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — the environment variable, or `BUNDLE_GEMFILE:` in the bundler app config — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — `BUNDLE_GEMFILE:` in the bundler app config, which outranks the environment variable as in `Bundler::Settings`, else the environment variable — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). -**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) 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 `vendor/cache/.gem` when present and not proven to be the patched artifact, since bundler installs from `vendor/cache` 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 `vendor/cache` 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 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) 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, else `vendor/cache`; a relative value is read against the project root. 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`) — 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. @@ -1220,7 +1220,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `content_mismatch_overwritten` | `skipped` (warning) | apply (default policy): a file matched NEITHER beforeHash nor afterHash and was overwritten with the full verified patched content. `--strict` turns this case into a `failed` event instead. | | `vendor_lock_checksums_unsupported` / `vendor_stale_lock_checksum` | `failed` | vendor (gem): an ambiguous/platform CHECKSUMS entry, or a v1-wired lock whose stale token blocks the hot path (run `vendor --revert` + re-vendor). | | `redirect_pypi_stale_install` | `redirect.warnings[]` (warning) | Hosted Python redirect: readable installed files differ from patched hashes. Read-only, repeated on re-scan, and excludes the package from same-run VEX. See the "Python stale-install guard" section. | -| `redirect_gem_stale_install` | `redirect.warnings[]` (warning) | scan `--mode hosted` (gem): a stale UNPATCHED materialization (installed gem, or committed `vendor/cache` archive) that `bundle install` will reuse instead of fetching the redirected patch; the detail carries the verified remedy. Full rules and flavors: the "Gem stale-install guard" section. | +| `redirect_gem_stale_install` | `redirect.warnings[]` (warning) | scan `--mode hosted` (gem): a stale UNPATCHED materialization (installed gem, or committed archive in bundler's cache dir — `vendor/cache` unless `cache_path` moves it) that `bundle install` will reuse instead of fetching the redirected patch; the detail carries the verified remedy. Full rules and flavors: the "Gem stale-install guard" section. | | `redirect_pipenv_refused` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): the Pipfile.lock pins another version or a non-registry / foreign source for the package — refused atomically across categories, and the patch is vetoed from the sibling Python rewriters (see the "Pipenv hosted redirect" section). | | `redirect_pipenv_skipped` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): no entry for the package, pipfile-spec < 6, an unparseable lock or a digest-less patch — nothing rewritten here; the sibling rewriters proceed. | | `redirect_pipenv_installer_unknown` | `redirect.warnings[]` (warning) | scan `--mode hosted` (pipenv): the lock was rewritten with the modern `file` reference because no `pipenv` answered on PATH; Pipenv 7–11 projects need `path` — put that pipenv on PATH or set `SOCKET_PIPENV_MAJOR`. | diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index e45450de5..292a4ebc2 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -253,7 +253,7 @@ fn gem_stale_cache_warning(purl: &str, cache_path: &Path) -> serde_json::Value { "detail": format!( "{purl} was switched to its hosted patch, but the \ project's committed bundler cache still holds an UNPATCHED \ - archive at {} — bundler installs from vendor/cache in preference \ + archive at {} — bundler installs from its cache dir in preference \ to fetching, so installs (fresh checkouts included) keep \ materializing the vulnerable upstream bytes. Remove that file, \ run `bundle install` so bundler fetches the patched gem, and \ @@ -378,6 +378,9 @@ async fn gem_stale_install_warnings( } let mut dir_state: std::collections::BTreeMap = std::collections::BTreeMap::new(); + // Bundler's configured cache dir (`cache_path`, default vendor/cache): + // 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 { let Some(pkg) = &found[index] else { @@ -418,10 +421,7 @@ async fn gem_stale_install_warnings( } let mut folded_cache: Option = None; if dir.starts_with(cwd) { - let project_cache = cwd - .join("vendor") - .join("cache") - .join(format!("{}.gem", j.leaf)); + let project_cache = app_cache.join(format!("{}.gem", j.leaf)); if project_cache.is_file() { let proven_patched = match ( gem_artifact_shas.get(&gem_sha_key(&j.purl)), @@ -467,10 +467,7 @@ async fn gem_stale_install_warnings( let Some((_, name, version)) = purl_parts(purl) else { continue; }; - let cache_path = cwd - .join("vendor") - .join("cache") - .join(format!("{name}-{version}.gem")); + let cache_path = app_cache.join(format!("{name}-{version}.gem")); if !cache_path.is_file() { continue; } @@ -3456,6 +3453,102 @@ mod tests { assert!(out.warnings.is_empty()); } + /// #483: bundler's cache dir is a setting (`bundle config set --local + /// cache_path vendor/gems` → `BUNDLE_CACHE_PATH` in `.bundle/config`). + /// A committed archive at the CONFIGURED path is what `bundle install` + /// installs from, so it warns standalone (fresh checkout, no installed + /// dir) and joins a stale install's delete list (folded) — and the + /// default `vendor/cache`, which bundler no longer reads, is ignored. + #[tokio::test] + async fn gem_stale_probe_follows_the_configured_bundle_cache_path() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join(".bundle")).unwrap(); + std::fs::write( + tmp.path().join(".bundle/config"), + "---\nBUNDLE_PATH: \"vendor/bundle\"\nBUNDLE_CACHE_PATH: \"vendor/gems\"\n", + ) + .unwrap(); + let configured = tmp + .path() + .join("vendor") + .join("gems") + .join(format!("{GEM_LEAF}.gem")); + std::fs::create_dir_all(configured.parent().unwrap()).unwrap(); + std::fs::write(&configured, b"upstream archive bytes").unwrap(); + let mut shas = std::collections::BTreeMap::new(); + shas.insert( + ("stale-unit".to_string(), "1.0.0".to_string()), + "0".repeat(64), // the patched artifact's sha — differs + ); + + // Standalone: a fresh checkout with only the configured cache. + let out = gem_stale_install_warnings( + tmp.path(), + false, + None, + &one_confirmed(), + &one_record(), + &shas, + ) + .await; + assert_eq!(out.warnings.len(), 1, "configured cache must warn"); + let detail = detail_of(&out.warnings[0]); + assert!( + detail.contains(&configured.display().to_string()), + "{detail}" + ); + assert_eq!( + out.stale_purls, + std::collections::BTreeSet::from([GEM_PURL.to_string()]), + "the in-run VEX must withhold the attestation" + ); + + // Folded: a stale install beside it gets the configured archive in + // its delete list, in one warning. + materialize_gem(tmp.path(), GEM_UPSTREAM); + let out = gem_stale_install_warnings( + tmp.path(), + false, + None, + &one_confirmed(), + &one_record(), + &shas, + ) + .await; + assert_eq!(out.warnings.len(), 1, "one warning, cache folded in"); + let detail = detail_of(&out.warnings[0]); + assert!( + detail.contains(&configured.display().to_string()), + "the configured archive must join the delete list: {detail}" + ); + + // A leftover default vendor/cache archive is not what bundler reads + // once cache_path moves it: it neither warns nor joins the list. + std::fs::remove_file(&configured).unwrap(); + let default = tmp + .path() + .join("vendor") + .join("cache") + .join(format!("{GEM_LEAF}.gem")); + std::fs::create_dir_all(default.parent().unwrap()).unwrap(); + std::fs::write(&default, b"upstream archive bytes").unwrap(); + let out = gem_stale_install_warnings( + tmp.path(), + false, + None, + &one_confirmed(), + &one_record(), + &shas, + ) + .await; + assert_eq!(out.warnings.len(), 1, "the stale install still warns"); + let detail = detail_of(&out.warnings[0]); + assert!( + !detail.contains(&default.display().to_string()), + "vendor/cache is not bundler's cache dir here: {detail}" + ); + } + /// Committed `vendor/cache` fold, UNKNOWN-sha arm (`_ => false`): when /// the run carries NO artifact sha for the gem (empty shas map — e.g. a /// reference served without a gem checksum), a committed archive beside 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 4c2aa5327..e6d04318b 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 @@ -540,6 +540,88 @@ async fn gem_hosted_stale_purl_is_not_vex_attested_in_the_same_run() { assert_eq!(env["status"], "error", "envelope: {env}"); } +/// #483: bundler's cache dir is the `cache_path` setting — `bundle config +/// set --local cache_path vendor/gems` (a committed `.bundle/config`) or a +/// `BUNDLE_CACHE_PATH` export. A fresh checkout whose committed archive at +/// that path is the UNPATCHED upstream `.gem` installs those bytes, so the +/// standalone cache warning must name it and the same run's `--vex` must +/// not attest the purl — exactly as for the default `vendor/cache`. +#[tokio::test(flavor = "multi_thread")] +async fn gem_hosted_stale_archive_at_configured_cache_path_warns_and_is_not_attested() { + let server = MockServer::start().await; + mount_api(&server, None).await; + for (label, config, env) in [ + ( + "app-config", + Some("---\nBUNDLE_CACHE_PATH: \"vendor/gems\"\n"), + &[][..], + ), + ("env", None, &[("BUNDLE_CACHE_PATH", "vendor/gems")][..]), + ] { + 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(config) = config { + std::fs::create_dir_all(proj.join(".bundle")).unwrap(); + std::fs::write(proj.join(".bundle").join("config"), config).unwrap(); + } + let archive = proj + .join("vendor") + .join("gems") + .join(format!("{DEP}-{DEP_VERSION}.gem")); + std::fs::create_dir_all(archive.parent().unwrap()).unwrap(); + std::fs::write(&archive, b"upstream-gem-archive-bytes").unwrap(); + + let vex_path = proj.join("out.vex.json"); + let (code, stdout, stderr) = common::run_with_env( + &proj, + &[ + "scan", + "--mode", + "hosted", + "--json", + "--yes", + "--cwd", + proj.to_str().unwrap(), + "--api-url", + &server.uri(), + "--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); + let warnings = stale_warnings(&env); + assert_eq!( + warnings.len(), + 1, + "{label}: the configured cache archive must warn: {env}\nstderr:\n{stderr}" + ); + assert!( + warnings[0].contains(&archive.display().to_string()), + "{label}: the warning must name the configured archive: {}", + warnings[0] + ); + if let Ok(doc) = std::fs::read_to_string(&vex_path) { + assert!( + !doc.contains(PURL), + "{label}: a stale purl must never be attested by the same run's VEX:\n{doc}" + ); + } + assert_ne!( + code, 0, + "{label}: an all-stale --vex run must fail, not attest.\nstdout:\n{stdout}" + ); + } +} + /// 7. Manifest-less VEX (no `.socket/manifest.json` — hosted never writes /// one) over the stale-install scenario, before and after the prescribed /// fix. The two post-install lock shapes are the ones REAL bundler writes From a757732502ecd579f4c70419c54064c063e037c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:39:29 +0000 Subject: [PATCH 4/4] Skip the bundle app config under BUNDLE_IGNORE_CONFIG Bundler's load_config returns {} whenever BUNDLE_IGNORE_CONFIG is set, so a cache_path or gemfile setting in .bundle/config is then ignored. The stale-install guard still followed the ignored cache_path, skipped the vendor/cache archive bundler actually installs from, and attested the purl. Both settings now read the app config through one reader that honors the switch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MWt5CXPnmqVEUZe4wnCfgX --- CHANGELOG.md | 2 + crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/e2e_redirect_gem_stale_install.rs | 26 +++++-- .../src/crawlers/ruby_crawler.rs | 72 +++++++++++++++---- 4 files changed, 79 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 839af1192..42b15f8de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,8 @@ limits, and required install commands. the committed archive in Bundler's configured cache dir (`cache_path` / `BUNDLE_CACHE_PATH`) instead of always `vendor/cache`, so a stale archive there now warns and keeps the same run's VEX from attesting it (#483). + Both settings skip `.bundle/config` under `BUNDLE_IGNORE_CONFIG`, as Bundler + does. - **npm dependencies installed from git, a URL or `file:` are no longer reported patched.** npm installs such a dependency from the dependent's spec (`github:user/repo`, `https://…/x.tgz`, `file:…`) and ignores the diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bb9363141..6491af8d1 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -165,7 +165,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`; when `.mvn/wrapper/maven-wrapper.properties` pins a Maven older than 3.9.4, which ignores those files, the additive warning `redirect_maven_trusted_checksums_unenforced`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). -**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) 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, else `vendor/cache`; a relative value is read against the project root. 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) 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, else `vendor/cache`; a relative value is read against the project root. With `BUNDLE_IGNORE_CONFIG` set (any value) bundler reads no config file, so the app config is 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`) — NOT `pipenv uninstall`, which rewrites the Pipfile and re-locks the patch away. The vendored backend emits the twin `pypi_pipenv_stale_install` (`skipped` warning event). **Rollback** (v5.0, upstream restore): each hosted entry gets its registry shape back — `"version": "=="`, the entry's own `index` carried back unchanged (refused unless it — and the Pipfile's explicit `index`, if any — names a PyPI source in `_meta.sources`), and every release file's sha256 from PyPI's JSON API (`SOCKET_PYPI_JSON_API`), sorted by filename as Pipenv records them; an entry that pins another version beside the hosted reference is refused with the `git checkout` remedy (see "Hosted unwind coverage"). A Pipfile names no project, so a same-run `--vex` on a Pipenv project needs `--vex-product` (or a git remote) to detect a product purl. **Discovery**: `Pipfile.lock` is part of the lockfile inventory (every category's `==` pins, with the lock's digest set as `Sha256AnyOf` integrity so a lock-only checkout can be vendored by fetching the pure wheel through PyPI's JSON API — only when `_meta.sources` name the public index; a private-index lock stays discovery-only and never reaches pypi.org), and Socket's own hosted / vendored references stay discoverable as the package they replace, so a re-scan of an already-redirected or already-vendored lock-only checkout re-confirms it (`--vex` attests, vendored reports `already_vendored`) instead of finding nothing. diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs index e6d04318b..1fed4afd2 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 @@ -545,18 +545,30 @@ async fn gem_hosted_stale_purl_is_not_vex_attested_in_the_same_run() { /// `BUNDLE_CACHE_PATH` export. A fresh checkout whose committed archive at /// that path is the UNPATCHED upstream `.gem` installs those bytes, so the /// standalone cache warning must name it and the same run's `--vex` must -/// not attest the purl — exactly as for the default `vendor/cache`. +/// not attest the purl — exactly as for the default `vendor/cache`. Under +/// `BUNDLE_IGNORE_CONFIG` the committed setting is ignored, by bundler and +/// by the guard alike, so the default `vendor/cache` archive still counts. #[tokio::test(flavor = "multi_thread")] async fn gem_hosted_stale_archive_at_configured_cache_path_warns_and_is_not_attested() { let server = MockServer::start().await; mount_api(&server, None).await; - for (label, config, env) in [ + let moved = "---\nBUNDLE_CACHE_PATH: \"vendor/gems\"\n"; + for (label, config, env, cache_dir) in [ + ("app-config", Some(moved), &[][..], "gems"), ( - "app-config", - Some("---\nBUNDLE_CACHE_PATH: \"vendor/gems\"\n"), - &[][..], + "env", + None, + &[("BUNDLE_CACHE_PATH", "vendor/gems")][..], + "gems", + ), + // BUNDLE_IGNORE_CONFIG: bundler skips the file, so the ignored + // `cache_path` moves nothing and `vendor/cache` is still installed. + ( + "ignore-config", + Some(moved), + &[("BUNDLE_IGNORE_CONFIG", "1")][..], + "cache", ), - ("env", None, &[("BUNDLE_CACHE_PATH", "vendor/gems")][..]), ] { let tmp = tempfile::tempdir().unwrap(); let proj = tmp.path().join("proj"); @@ -568,7 +580,7 @@ async fn gem_hosted_stale_archive_at_configured_cache_path_warns_and_is_not_atte } let archive = proj .join("vendor") - .join("gems") + .join(cache_dir) .join(format!("{DEP}-{DEP_VERSION}.gem")); std::fs::create_dir_all(archive.parent().unwrap()).unwrap(); std::fs::write(&archive, b"upstream-gem-archive-bytes").unwrap(); diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index da9d6d781..fdeeb5153 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -952,25 +952,47 @@ pub async fn bundler_loaded_manifest(root: &Path) -> crate::formats::gem::manife root, std::env::var_os("BUNDLE_GEMFILE").as_deref(), std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), + bundler_ignores_config(), ) .await } /// [`bundler_loaded_manifest`] with the environment passed explicitly (hermetic -/// tests). +/// tests). `ignore_config` is [`bundler_ignores_config`]. pub async fn bundler_loaded_manifest_with_env( root: &Path, gemfile_env: Option<&OsStr>, app_config_env: Option<&OsStr>, + ignore_config: bool, ) -> crate::formats::gem::manifest::LoadedManifest { - let config = bundler_app_config_dir(root, app_config_env).join("config"); - let config_value = crate::utils::fs::read_regular_to_string(&config) + let config_value = read_app_config(root, app_config_env, ignore_config) .await - .ok() .and_then(|text| crate::formats::gem::manifest::config_gemfile(&text)); crate::formats::gem::manifest::classify(root, gemfile_env, config_value.as_deref()) } +/// Whether bundler skips its config files: `Bundler::Settings#ignore_config?` +/// is `ENV["BUNDLE_IGNORE_CONFIG"]`, so any value set (even an empty one) +/// switches them off and every setting comes from the environment alone. +pub(crate) fn bundler_ignores_config() -> bool { + std::env::var_os("BUNDLE_IGNORE_CONFIG").is_some() +} + +/// The app config file's text (`$BUNDLE_APP_CONFIG/config`, else +/// `/.bundle/config`), or `None` when it is missing, unreadable, or +/// `ignore_config` is set — bundler's `load_config` then returns `{}`. +async fn read_app_config( + root: &Path, + app_config_env: Option<&OsStr>, + ignore_config: bool, +) -> Option { + if ignore_config { + return None; + } + let config = bundler_app_config_dir(root, app_config_env).join("config"); + crate::utils::fs::read_regular_to_string(&config).await.ok() +} + /// Bundler's app-config dir for `root`, following `Bundler.app_config_path` /// exactly: `$BUNDLE_APP_CONFIG` when set (a relative value resolves against /// the project root, NOT the process cwd), else `/.bundle` — e.g. the @@ -998,6 +1020,7 @@ pub async fn bundler_app_cache_dir(root: &Path) -> PathBuf { root, std::env::var_os("BUNDLE_CACHE_PATH").as_deref(), std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), + bundler_ignores_config(), ) .await } @@ -1012,15 +1035,16 @@ pub async fn bundler_app_cache_dir(root: &Path) -> PathBuf { /// committed archive is hashed and named in a warning), so unlike a /// config-sourced `BUNDLE_PATH` it needs no containment: a value that /// points outside the project names exactly the file bundler installs from. +/// With `ignore_config` ([`bundler_ignores_config`]) the file is skipped and +/// only the environment and the default count. pub async fn bundler_app_cache_dir_with_env( root: &Path, cache_env: Option<&OsStr>, app_config_env: Option<&OsStr>, + ignore_config: bool, ) -> PathBuf { - let config = bundler_app_config_dir(root, app_config_env).join("config"); - let configured = crate::utils::fs::read_regular_to_string(&config) + let configured = read_app_config(root, app_config_env, ignore_config) .await - .ok() .and_then(|text| bundle_config_setting(&text, "BUNDLE_CACHE_PATH")) .map(PathBuf::from) .or_else(|| cache_env.filter(|v| !v.is_empty()).map(PathBuf::from)); @@ -1191,7 +1215,7 @@ mod tests { "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n", ) .unwrap(); - let m = bundler_loaded_manifest_with_env(dir.path(), None, None).await; + let m = bundler_loaded_manifest_with_env(dir.path(), None, None, false).await; assert!(matches!( m, crate::formats::gem::manifest::LoadedManifest::Unsupported { @@ -1204,9 +1228,13 @@ mod tests { dir.path(), None, Some(std::ffi::OsStr::new("elsewhere")), + false, ) .await; assert_eq!(m, crate::formats::gem::manifest::LoadedManifest::Default); + // BUNDLE_IGNORE_CONFIG: bundler reads no config file at all. + let m = bundler_loaded_manifest_with_env(dir.path(), None, None, true).await; + assert_eq!(m, crate::formats::gem::manifest::LoadedManifest::Default); } /// #507: a committed `bundle config set --local gemfile Gemfile.next` @@ -1225,6 +1253,7 @@ mod tests { dir.path(), Some(std::ffi::OsStr::new("Gemfile")), None, + false, ) .await; assert_eq!( @@ -1244,18 +1273,18 @@ mod tests { let root = dir.path(); let default = root.join("vendor").join("cache"); assert_eq!( - bundler_app_cache_dir_with_env(root, None, None).await, + bundler_app_cache_dir_with_env(root, None, None, false).await, default ); // The environment alone moves it. let env = std::ffi::OsStr::new("vendor/env-gems"); assert_eq!( - bundler_app_cache_dir_with_env(root, Some(env), None).await, + bundler_app_cache_dir_with_env(root, Some(env), None, false).await, root.join("vendor").join("env-gems") ); // An empty value is unset. assert_eq!( - bundler_app_cache_dir_with_env(root, Some(std::ffi::OsStr::new("")), None).await, + bundler_app_cache_dir_with_env(root, Some(std::ffi::OsStr::new("")), None, false).await, default ); // `bundle config set --local cache_path vendor/gems` outranks it. @@ -1267,11 +1296,11 @@ mod tests { .unwrap(); let configured = root.join("vendor").join("gems"); assert_eq!( - bundler_app_cache_dir_with_env(root, None, None).await, + bundler_app_cache_dir_with_env(root, None, None, false).await, configured ); assert_eq!( - bundler_app_cache_dir_with_env(root, Some(env), None).await, + bundler_app_cache_dir_with_env(root, Some(env), None, false).await, configured ); // BUNDLE_APP_CONFIG moves the config file: the env value applies. @@ -1279,11 +1308,21 @@ mod tests { bundler_app_cache_dir_with_env( root, Some(env), - Some(std::ffi::OsStr::new("elsewhere")) + Some(std::ffi::OsStr::new("elsewhere")), + false, ) .await, root.join("vendor").join("env-gems") ); + // BUNDLE_IGNORE_CONFIG skips the file: the env value, else the default. + assert_eq!( + bundler_app_cache_dir_with_env(root, Some(env), None, true).await, + root.join("vendor").join("env-gems") + ); + assert_eq!( + bundler_app_cache_dir_with_env(root, None, None, true).await, + default + ); // An absolute value stands alone. let abs = root.join("shared-cache"); std::fs::write( @@ -1291,7 +1330,10 @@ mod tests { format!("---\nBUNDLE_CACHE_PATH: \"{}\"\n", abs.display()), ) .unwrap(); - assert_eq!(bundler_app_cache_dir_with_env(root, None, None).await, abs); + assert_eq!( + bundler_app_cache_dir_with_env(root, None, None, false).await, + abs + ); } #[test]