Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,17 @@ hand (the `postinstall`/`dependencies` entries, the `socket-patch[hook]` depende
`post-install-cmd`/`post-update-cmd` entries). The `socket-patch-hook` wheel and the
`socket-patch-bundler` gem are no longer published.

Bundler also keeps a machine-local registration of the plugin in the uncommitted
`.bundle/plugin/index`, which v4's `setup --remove` cleared and nothing in v5 does. Every
checkout that ran `bundle install` under v4 must run `bundle plugin uninstall socket-patch` (or
delete `.bundle/plugin/`) once: with the plugin directory gone, Bundler 2.3–2.5 fail every
`bundle install` with a `LoadError` and 2.6+ warn on each run. `scan` (gem ecosystem selected,
project mode) and `apply` (gem patches in scope, project mode) detect a registration of
`socket-patch` at a path that no longer exists and report it as a `gem_bundler_plugin_stale`
run-level `warnings[]` entry under `--json` (detail names the registered path, the index file and
the remedy), and as one stderr `Warning: …` line otherwise (`apply` gates it on `!--silent`). A
registration whose directory still exists is a working v4 setup and is not reported.

Prefer hosted or vendored mode: their lockfile (and `.socket/vendor/`) edits are the persistence, so
no Socket Patch install hook is needed. Agent mode (`scan --mode agent`, `get --mode agent`, `apply`) patches the installed tree
in place, which the next package-manager install reverts; wire it into CI yourself:
Expand Down
14 changes: 13 additions & 1 deletion crates/socket-patch-cli/src/commands/apply.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use clap::Args;
use socket_patch_core::api::client::{get_api_client_with_overrides, ApiClient};
use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning;
use socket_patch_core::crawlers::ruby_crawler::{
config_path_ignored_warning, stale_plugin_registration_warning,
};
use socket_patch_core::crawlers::{
bun_uses_global_store, detect_npm_pkg_manager, Ecosystem, NpmPkgManager, RubyCrawler,
};
Expand Down Expand Up @@ -1978,6 +1980,16 @@ async fn apply_patches_inner(
detail,
});
}
// A Bundler plugin registration v4's `setup` left in this checkout,
// pointing at a plugin dir the v5 migration deleted (#1295).
if gem_discovery.is_some() {
if let Some((code, detail)) = stale_plugin_registration_warning(&args.common.cwd).await {
run_warnings.push(RunWarning {
code: code.to_string(),
detail,
});
}
}
let mut fallback_skips: Vec<FallbackHomeSkip> = Vec::new();

// Multi-copy aware: npm nests genuine duplicates of one `name@version`
Expand Down
14 changes: 13 additions & 1 deletion crates/socket-patch-cli/src/commands/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ use socket_patch_core::api::client::{
is_fallback_candidate, ApiClient, ApiError,
};
use socket_patch_core::api::types::{BatchPackagePatches, BatchSearchResponse, PatchSearchResult};
use socket_patch_core::crawlers::ruby_crawler::config_path_ignored_warning;
use socket_patch_core::crawlers::ruby_crawler::{
config_path_ignored_warning, stale_plugin_registration_warning,
};
use socket_patch_core::crawlers::Ecosystem;
use socket_patch_core::manifest::schema::PatchManifest;
use socket_patch_core::telemetry::{
Expand Down Expand Up @@ -1880,6 +1882,16 @@ async fn run_scan(
layout_refusals.push((code.to_string(), detail));
}
}
// A Bundler plugin registration v4's `setup` left in this checkout,
// pointing at a plugin dir the v5 migration deleted (#1295).
if args.common.ecosystem_selected(Ecosystem::Gem)
&& !args.common.global
&& args.common.global_prefix.is_none()
{
if let Some((code, detail)) = stale_plugin_registration_warning(&args.common.cwd).await {
layout_refusals.push((code.to_string(), detail));
}
}
// Supplement purls, captured for the path-scope filter below: their
// `path` fields are fabricated placeholders, so a path-scoped scan
// excludes them (with a counted warning) instead of glob-matching
Expand Down
5 changes: 4 additions & 1 deletion crates/socket-patch-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,10 @@ const RETIRED_SUBCOMMANDS: &[(&str, &str)] = &[
"was removed in v5.0, together with the install hooks it wired. In CI, run \
`socket-patch apply` after each install (agent mode), or switch to \
`socket-patch scan --mode hosted` or `--mode vendored`, whose lockfile edits \
need no hook",
need no hook. To remove the old Bundler plugin, delete the Gemfile \
`plugin \"socket-patch\"` block and `.socket/bundler-plugin/`, then run \
`bundle plugin uninstall socket-patch` in every checkout that ran \
`bundle install` with it",
),
(
"unlock",
Expand Down
163 changes: 163 additions & 0 deletions crates/socket-patch-cli/tests/apply/gem_stale_bundler_plugin.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
//! #1295: a Bundler plugin registration v4's `setup` left in a checkout.
//!
//! v4's `setup --remove` cleared `.bundle/plugin/index`; v5 has no
//! `setup`, and `.bundle/` is never committed, so every other checkout
//! that ran `bundle install` under v4 still registers `socket-patch` at
//! the `.socket/bundler-plugin/` the migration commit deleted. Bundler
//! 2.3-2.5 then fail every `bundle install` with a `LoadError`. scan and
//! apply on a gem project must say so, with the one-line fix.

use crate::common::{git_sha256, run_with_env};

use std::path::Path;

const PURL: &str = "pkg:gem/rack@3.1.0";
const CODE: &str = "gem_bundler_plugin_stale";

/// A Gemfile project whose `.bundle/plugin/index` registers
/// `socket-patch` the way Bundler writes it after a v4 `setup` +
/// `bundle install`. `.socket/bundler-plugin/` exists only when
/// `plugin_dir_present`. With `with_patch`, a `vendor/bundle` store holds
/// rack@3.1.0 and the manifest carries a patch for it.
fn build_project(root: &Path, plugin_dir_present: bool, with_patch: bool) -> String {
std::fs::write(root.join("Gemfile"), b"source 'https://rubygems.org'\n").unwrap();
let plugin_dir = root.join(".socket").join("bundler-plugin");
let dir = plugin_dir.display().to_string();
let index_dir = root.join(".bundle").join("plugin");
std::fs::create_dir_all(&index_dir).unwrap();
std::fs::write(
index_dir.join("index"),
format!(
"---\ncommands:\nhooks:\n before-install-all:\n - \"socket-patch\"\n\
load_paths:\n socket-patch:\n - \"{dir}/lib\"\nplugin_paths:\n \
socket-patch: \"{dir}\"\nsources:\n"
),
)
.unwrap();
if plugin_dir_present {
std::fs::create_dir_all(&plugin_dir).unwrap();
}
if with_patch {
let original = b"module Rack\n VERSION = 'VULNERABLE'\nend\n";
let mut patched = original.to_vec();
patched.extend_from_slice(b"# SOCKET-PATCHED\n");
let before_hash = git_sha256(original);
let after_hash = git_sha256(&patched);
let gem_lib = root.join("vendor/bundle/ruby/3.2.0/gems/rack-3.1.0/lib");
std::fs::create_dir_all(&gem_lib).unwrap();
std::fs::write(gem_lib.join("rack.rb"), original).unwrap();
let socket = root.join(".socket");
std::fs::create_dir_all(socket.join("blobs")).unwrap();
std::fs::write(
socket.join("manifest.json"),
format!(
r#"{{ "patches": {{
"{PURL}": {{
"uuid": "636f6e66-6967-4761-8264-000000001295",
"exportedAt": "2024-01-01T00:00:00Z",
"files": {{ "lib/rack.rb": {{
"beforeHash": "{before_hash}", "afterHash": "{after_hash}"
}}}},
"vulnerabilities": {{}}, "description": "stale-plugin fixture",
"license": "MIT", "tier": "free"
}}
}}}}"#
),
)
.unwrap();
std::fs::write(socket.join("blobs").join(&after_hash), &patched).unwrap();
}
dir
}

/// Run with no `gem` binary on PATH and the app config dir pinned to the
/// project's own `.bundle/`, so no ambient Bundler state is read.
fn run(root: &Path, args: &[&str]) -> (i32, String, String) {
let empty_path = root.join("empty-bin");
std::fs::create_dir_all(&empty_path).unwrap();
let app_config = root.join(".bundle");
let mut argv: Vec<&str> = args.to_vec();
let cwd = root.display().to_string();
argv.extend(["--cwd", cwd.as_str()]);
run_with_env(
root,
&argv,
&[
("PATH", empty_path.to_str().unwrap()),
("BUNDLE_APP_CONFIG", app_config.to_str().unwrap()),
],
)
}

fn stale_warning(env: &serde_json::Value) -> Option<String> {
env.get("warnings")?
.as_array()?
.iter()
.find(|w| w.get("code").and_then(|c| c.as_str()) == Some(CODE))
.and_then(|w| w.get("detail")?.as_str().map(str::to_string))
}

#[test]
fn scan_json_warns_about_stale_plugin_registration() {
let tmp = tempfile::tempdir().unwrap();
let dir = build_project(tmp.path(), false, false);
let (code, stdout, stderr) = run(tmp.path(), &["scan", "--json", "--yes"]);
assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}");
let env: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("scan must emit JSON: {e}; stdout={stdout}"));
let detail = stale_warning(&env)
.unwrap_or_else(|| panic!("warnings[] must carry {CODE}.\nenvelope: {env}"));
assert!(detail.contains(&dir), "detail names the path: {detail}");
assert!(
detail.contains("bundle plugin uninstall socket-patch"),
"detail carries the remedy: {detail}"
);
}

/// A v4 setup that is still wired loads fine: no warning.
#[test]
fn scan_json_is_quiet_while_the_plugin_dir_exists() {
let tmp = tempfile::tempdir().unwrap();
build_project(tmp.path(), true, false);
let (code, stdout, stderr) = run(tmp.path(), &["scan", "--json", "--yes"]);
assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}");
let env: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(stale_warning(&env), None, "envelope: {env}");
}

#[test]
fn apply_warns_about_stale_plugin_registration() {
let tmp = tempfile::tempdir().unwrap();
let dir = build_project(tmp.path(), false, true);
let (code, stdout, stderr) = run(
tmp.path(),
&["apply", "--json", "--offline", "--ecosystems", "gem"],
);
let env: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("apply must emit JSON: {e}; stdout={stdout}"));
assert_eq!(code, 0, "envelope: {env}\nstderr:\n{stderr}");
assert_eq!(env["summary"]["applied"], 1, "envelope: {env}");
let detail = stale_warning(&env)
.unwrap_or_else(|| panic!("warnings[] must carry {CODE}.\nenvelope: {env}"));
assert!(detail.contains(&dir), "detail names the path: {detail}");

// Human path: one stderr line, gated on --silent.
let (code, _out, stderr) = run(tmp.path(), &["apply", "--offline", "--ecosystems", "gem"]);
assert_eq!(code, 0, "stderr:\n{stderr}");
assert_eq!(
stderr
.matches("Warning: Bundler still registers the removed v4 socket-patch plugin")
.count(),
1,
"stderr:\n{stderr}"
);
let (code, _out, stderr) = run(
tmp.path(),
&["apply", "--offline", "--ecosystems", "gem", "--silent"],
);
assert_eq!(code, 0, "stderr:\n{stderr}");
assert!(
!stderr.contains("Bundler still registers"),
"stderr:\n{stderr}"
);
}
1 change: 1 addition & 0 deletions crates/socket-patch-cli/tests/apply/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod check_verifies_installed_tree;
mod cli_gem_variant_mismatch_policy;
mod covgap_commands_apply;
mod e2e_safety_advisories;
mod gem_stale_bundler_plugin;
mod in_process_gem_config_warning;
mod in_process_gem_fallback_home;
mod in_process_gem_multicopy;
Expand Down
6 changes: 6 additions & 0 deletions crates/socket-patch-cli/tests/cli_parse_main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ fn setup_subcommand_is_removed() {
assert!(text.contains("removed in v5.0"), "{text}");
assert!(text.contains("socket-patch apply"), "{text}");
assert!(!text.contains("self-update"), "{text}");
// #1295: removing the Bundler plugin by hand also needs the
// per-checkout deregistration `setup --remove` used to do.
assert!(
text.contains("bundle plugin uninstall socket-patch"),
"{text}"
);
}

#[test]
Expand Down
Loading
Loading