|
| 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 | +} |
0 commit comments