Skip to content

Commit da800c5

Browse files
Fix stale Bundler plugin registration after v5 (#1295) (#1367)
* Start fix for #1295 Assisted-by: Claude Code:claude-opus-5-5 * Warn about stale v4 Bundler plugin registrations v5 removed setup --remove, which cleared Bundler's machine-local plugin registration in .bundle/plugin/index. Checkouts that ran bundle install under v4 kept pointing at the deleted .socket/bundler-plugin/, so Bundler 2.3-2.5 fail every install with a LoadError and newer versions warn on each run. scan and apply on gem projects now report such a registration as gem_bundler_plugin_stale with the one-line fix, and the migration guide, CLI contract and retired-setup error name the per-checkout bundle plugin uninstall step. Fixes #1295 Assisted-by: Claude Code:claude-opus-5-5 * Keep unrelated upstream module untouched Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e1acd60 commit da800c5

9 files changed

Lines changed: 414 additions & 4 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,17 @@ hand (the `postinstall`/`dependencies` entries, the `socket-patch[hook]` depende
456456
`post-install-cmd`/`post-update-cmd` entries). The `socket-patch-hook` wheel and the
457457
`socket-patch-bundler` gem are no longer published.
458458

459+
Bundler also keeps a machine-local registration of the plugin in the uncommitted
460+
`.bundle/plugin/index`, which v4's `setup --remove` cleared and nothing in v5 does. Every
461+
checkout that ran `bundle install` under v4 must run `bundle plugin uninstall socket-patch` (or
462+
delete `.bundle/plugin/`) once: with the plugin directory gone, Bundler 2.3–2.5 fail every
463+
`bundle install` with a `LoadError` and 2.6+ warn on each run. `scan` (gem ecosystem selected,
464+
project mode) and `apply` (gem patches in scope, project mode) detect a registration of
465+
`socket-patch` at a path that no longer exists and report it as a `gem_bundler_plugin_stale`
466+
run-level `warnings[]` entry under `--json` (detail names the registered path, the index file and
467+
the remedy), and as one stderr `Warning: …` line otherwise (`apply` gates it on `!--silent`). A
468+
registration whose directory still exists is a working v4 setup and is not reported.
469+
459470
Prefer hosted or vendored mode: their lockfile (and `.socket/vendor/`) edits are the persistence, so
460471
no Socket Patch install hook is needed. Agent mode (`scan --mode agent`, `get --mode agent`, `apply`) patches the installed tree
461472
in place, which the next package-manager install reverts; wire it into CI yourself:

‎crates/socket-patch-cli/src/commands/apply.rs‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use clap::Args;
22
use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient};
3-
use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning;
3+
use socket_patch_core::crawlers::ruby_crawler::{
4+
config_path_ignored_warning, stale_plugin_registration_warning,
5+
};
46
use socket_patch_core::crawlers::{
57
bun_uses_global_store, detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler,
68
};
@@ -1978,6 +1980,16 @@ async fn apply_patches_inner(
19781980
detail,
19791981
});
19801982
}
1983+
// A Bundler plugin registration v4's `setup` left in this checkout,
1984+
// pointing at a plugin dir the v5 migration deleted (#1295).
1985+
if gem_discovery.is_some() {
1986+
if let Some((code, detail)) = stale_plugin_registration_warning(&args.common.cwd).await {
1987+
run_warnings.push(RunWarning {
1988+
code: code.to_string(),
1989+
detail,
1990+
});
1991+
}
1992+
}
19811993
let mut fallback_skips: Vec<FallbackHomeSkip> = Vec::new();
19821994

19831995
// Multi-copy aware: npm nests genuine duplicates of one `name@version`

‎crates/socket-patch-cli/src/commands/scan/mod.rs‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ use socket_patch_core::api::client::{
1313
is_fallback_candidate, ApiClient, ApiError,
1414
};
1515
use socket_patch_core::api::types::{BatchPackagePatches, BatchSearchResponse, PatchSearchResult};
16-
use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning;
16+
use socket_patch_core::crawlers::ruby_crawler::{
17+
config_path_ignored_warning, stale_plugin_registration_warning,
18+
};
1719
use socket_patch_core::crawlers::Ecosystem;
1820
use socket_patch_core::manifest::schema::PatchManifest;
1921
use socket_patch_core::telemetry::{
@@ -1880,6 +1882,16 @@ async fn run_scan(
18801882
layout_refusals.push((code.to_string(), detail));
18811883
}
18821884
}
1885+
// A Bundler plugin registration v4's `setup` left in this checkout,
1886+
// pointing at a plugin dir the v5 migration deleted (#1295).
1887+
if args.common.ecosystem_selected(Ecosystem::Gem)
1888+
&& !args.common.global
1889+
&& args.common.global_prefix.is_none()
1890+
{
1891+
if let Some((code, detail)) = stale_plugin_registration_warning(&args.common.cwd).await {
1892+
layout_refusals.push((code.to_string(), detail));
1893+
}
1894+
}
18831895
// Supplement purls, captured for the path-scope filter below: their
18841896
// `path` fields are fabricated placeholders, so a path-scoped scan
18851897
// excludes them (with a counted warning) instead of glob-matching

‎crates/socket-patch-cli/src/lib.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,10 @@ const RETIRED_SUBCOMMANDS: &[(&str, &str)] = &[
347347
"was removed in v5.0, together with the install hooks it wired. In CI, run \
348348
`socket-patch apply` after each install (agent mode), or switch to \
349349
`socket-patch scan --mode hosted` or `--mode vendored`, whose lockfile edits \
350-
need no hook",
350+
need no hook. To remove the old Bundler plugin, delete the Gemfile \
351+
`plugin \"socket-patch\"` block and `.socket/bundler-plugin/`, then run \
352+
`bundle plugin uninstall socket-patch` in every checkout that ran \
353+
`bundle install` with it",
351354
),
352355
(
353356
"unlock",
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
//! #1295: a Bundler plugin registration v4's `setup` left in a checkout.
2+
//!
3+
//! v4's `setup --remove` cleared `.bundle/plugin/index`; v5 has no
4+
//! `setup`, and `.bundle/` is never committed, so every other checkout
5+
//! that ran `bundle install` under v4 still registers `socket-patch` at
6+
//! the `.socket/bundler-plugin/` the migration commit deleted. Bundler
7+
//! 2.3-2.5 then fail every `bundle install` with a `LoadError`. scan and
8+
//! apply on a gem project must say so, with the one-line fix.
9+
10+
use crate::common::{git_sha256, run_with_env};
11+
12+
use std::path::Path;
13+
14+
const PURL: &str = "pkg:gem/rack@3.1.0";
15+
const CODE: &str = "gem_bundler_plugin_stale";
16+
17+
/// A Gemfile project whose `.bundle/plugin/index` registers
18+
/// `socket-patch` the way Bundler writes it after a v4 `setup` +
19+
/// `bundle install`. `.socket/bundler-plugin/` exists only when
20+
/// `plugin_dir_present`. With `with_patch`, a `vendor/bundle` store holds
21+
/// rack@3.1.0 and the manifest carries a patch for it.
22+
fn build_project(root: &Path, plugin_dir_present: bool, with_patch: bool) -> String {
23+
std::fs::write(root.join("Gemfile"), b"source 'https://rubygems.org'\n").unwrap();
24+
let plugin_dir = root.join(".socket").join("bundler-plugin");
25+
let dir = plugin_dir.display().to_string();
26+
let index_dir = root.join(".bundle").join("plugin");
27+
std::fs::create_dir_all(&index_dir).unwrap();
28+
std::fs::write(
29+
index_dir.join("index"),
30+
format!(
31+
"---\ncommands:\nhooks:\n before-install-all:\n - \"socket-patch\"\n\
32+
load_paths:\n socket-patch:\n - \"{dir}/lib\"\nplugin_paths:\n \
33+
socket-patch: \"{dir}\"\nsources:\n"
34+
),
35+
)
36+
.unwrap();
37+
if plugin_dir_present {
38+
std::fs::create_dir_all(&plugin_dir).unwrap();
39+
}
40+
if with_patch {
41+
let original = b"module Rack\n VERSION = 'VULNERABLE'\nend\n";
42+
let mut patched = original.to_vec();
43+
patched.extend_from_slice(b"# SOCKET-PATCHED\n");
44+
let before_hash = git_sha256(original);
45+
let after_hash = git_sha256(&patched);
46+
let gem_lib = root.join("vendor/bundle/ruby/3.2.0/gems/rack-3.1.0/lib");
47+
std::fs::create_dir_all(&gem_lib).unwrap();
48+
std::fs::write(gem_lib.join("rack.rb"), original).unwrap();
49+
let socket = root.join(".socket");
50+
std::fs::create_dir_all(socket.join("blobs")).unwrap();
51+
std::fs::write(
52+
socket.join("manifest.json"),
53+
format!(
54+
r#"{{ "patches": {{
55+
"{PURL}": {{
56+
"uuid": "636f6e66-6967-4761-8264-000000001295",
57+
"exportedAt": "2024-01-01T00:00:00Z",
58+
"files": {{ "lib/rack.rb": {{
59+
"beforeHash": "{before_hash}", "afterHash": "{after_hash}"
60+
}}}},
61+
"vulnerabilities": {{}}, "description": "stale-plugin fixture",
62+
"license": "MIT", "tier": "free"
63+
}}
64+
}}}}"#
65+
),
66+
)
67+
.unwrap();
68+
std::fs::write(socket.join("blobs").join(&after_hash), &patched).unwrap();
69+
}
70+
dir
71+
}
72+
73+
/// Run with no `gem` binary on PATH and the app config dir pinned to the
74+
/// project's own `.bundle/`, so no ambient Bundler state is read.
75+
fn run(root: &Path, args: &[&str]) -> (i32, String, String) {
76+
let empty_path = root.join("empty-bin");
77+
std::fs::create_dir_all(&empty_path).unwrap();
78+
let app_config = root.join(".bundle");
79+
let mut argv: Vec<&str> = args.to_vec();
80+
let cwd = root.display().to_string();
81+
argv.extend(["--cwd", cwd.as_str()]);
82+
run_with_env(
83+
root,
84+
&argv,
85+
&[
86+
("PATH", empty_path.to_str().unwrap()),
87+
("BUNDLE_APP_CONFIG", app_config.to_str().unwrap()),
88+
],
89+
)
90+
}
91+
92+
fn stale_warning(env: &serde_json::Value) -> Option<String> {
93+
env.get("warnings")?
94+
.as_array()?
95+
.iter()
96+
.find(|w| w.get("code").and_then(|c| c.as_str()) == Some(CODE))
97+
.and_then(|w| w.get("detail")?.as_str().map(str::to_string))
98+
}
99+
100+
#[test]
101+
fn scan_json_warns_about_stale_plugin_registration() {
102+
let tmp = tempfile::tempdir().unwrap();
103+
let dir = build_project(tmp.path(), false, false);
104+
let (code, stdout, stderr) = run(tmp.path(), &["scan", "--json", "--yes"]);
105+
assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}");
106+
let env: serde_json::Value = serde_json::from_str(stdout.trim())
107+
.unwrap_or_else(|e| panic!("scan must emit JSON: {e}; stdout={stdout}"));
108+
let detail = stale_warning(&env)
109+
.unwrap_or_else(|| panic!("warnings[] must carry {CODE}.\nenvelope: {env}"));
110+
assert!(detail.contains(&dir), "detail names the path: {detail}");
111+
assert!(
112+
detail.contains("bundle plugin uninstall socket-patch"),
113+
"detail carries the remedy: {detail}"
114+
);
115+
}
116+
117+
/// A v4 setup that is still wired loads fine: no warning.
118+
#[test]
119+
fn scan_json_is_quiet_while_the_plugin_dir_exists() {
120+
let tmp = tempfile::tempdir().unwrap();
121+
build_project(tmp.path(), true, false);
122+
let (code, stdout, stderr) = run(tmp.path(), &["scan", "--json", "--yes"]);
123+
assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}");
124+
let env: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
125+
assert_eq!(stale_warning(&env), None, "envelope: {env}");
126+
}
127+
128+
#[test]
129+
fn apply_warns_about_stale_plugin_registration() {
130+
let tmp = tempfile::tempdir().unwrap();
131+
let dir = build_project(tmp.path(), false, true);
132+
let (code, stdout, stderr) = run(
133+
tmp.path(),
134+
&["apply", "--json", "--offline", "--ecosystems", "gem"],
135+
);
136+
let env: serde_json::Value = serde_json::from_str(stdout.trim())
137+
.unwrap_or_else(|e| panic!("apply must emit JSON: {e}; stdout={stdout}"));
138+
assert_eq!(code, 0, "envelope: {env}\nstderr:\n{stderr}");
139+
assert_eq!(env["summary"]["applied"], 1, "envelope: {env}");
140+
let detail = stale_warning(&env)
141+
.unwrap_or_else(|| panic!("warnings[] must carry {CODE}.\nenvelope: {env}"));
142+
assert!(detail.contains(&dir), "detail names the path: {detail}");
143+
144+
// Human path: one stderr line, gated on --silent.
145+
let (code, _out, stderr) = run(tmp.path(), &["apply", "--offline", "--ecosystems", "gem"]);
146+
assert_eq!(code, 0, "stderr:\n{stderr}");
147+
assert_eq!(
148+
stderr
149+
.matches("Warning: Bundler still registers the removed v4 socket-patch plugin")
150+
.count(),
151+
1,
152+
"stderr:\n{stderr}"
153+
);
154+
let (code, _out, stderr) = run(
155+
tmp.path(),
156+
&["apply", "--offline", "--ecosystems", "gem", "--silent"],
157+
);
158+
assert_eq!(code, 0, "stderr:\n{stderr}");
159+
assert!(
160+
!stderr.contains("Bundler still registers"),
161+
"stderr:\n{stderr}"
162+
);
163+
}

‎crates/socket-patch-cli/tests/apply/main.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ mod check_verifies_installed_tree;
1616
mod cli_gem_variant_mismatch_policy;
1717
mod covgap_commands_apply;
1818
mod e2e_safety_advisories;
19+
mod gem_stale_bundler_plugin;
1920
mod in_process_gem_config_warning;
2021
mod in_process_gem_fallback_home;
2122
mod in_process_gem_multicopy;

‎crates/socket-patch-cli/tests/cli_parse_main.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ fn setup_subcommand_is_removed() {
171171
assert!(text.contains("removed in v5.0"), "{text}");
172172
assert!(text.contains("socket-patch apply"), "{text}");
173173
assert!(!text.contains("self-update"), "{text}");
174+
// #1295: removing the Bundler plugin by hand also needs the
175+
// per-checkout deregistration `setup --remove` used to do.
176+
assert!(
177+
text.contains("bundle plugin uninstall socket-patch"),
178+
"{text}"
179+
);
174180
}
175181

176182
#[test]

0 commit comments

Comments
 (0)