From 3b7c3d37d4d6fa9f98a6e6e0b3eac307f22e9d7c Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 18 Aug 2026 21:02:23 -0400 Subject: [PATCH 1/4] test(gem): red regression tests for the bundler >= 2.2 setup floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundler 1.x cannot load the `plugin ... path:` directive gem setup writes (Plugin::DSL undef's :path; the 1.x plugin installer knows only git/rubygems sources), so a wired project fails EVERY later `bundle install` with exit 7 before plugin registration, with an error that never names socket-patch — while setup and setup --check keep reporting "configured" (campaign-confirmed on bundler 1.17.3). Red now: - core: add_plugin_directive wires a Gemfile.lock'd BUNDLED WITH 1.17.3 project (real + dry-run) - cli: setup exits 0/"success" on the same project; setup --check says "configured" on a wired project after a 1.x lock lands Co-Authored-By: Claude Fable 5 --- .../tests/setup_matrix_gem.rs | 119 ++++++++++++++++++ .../socket-patch-core/src/setup/gem/update.rs | 114 +++++++++++++++++ 2 files changed, 233 insertions(+) diff --git a/crates/socket-patch-cli/tests/setup_matrix_gem.rs b/crates/socket-patch-cli/tests/setup_matrix_gem.rs index cb0c202a..570a0af2 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_gem.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_gem.rs @@ -356,6 +356,125 @@ mod host_guard { ); } + /// A Gemfile.lock pinning bundler 1.x, exactly as `bundle install` under + /// bundler 1.17.3 writes it (independent oracle for the version gate — + /// the lock probe works even where no `bundle` is on PATH). + const LOCK_1X: &str = "GEM\n remote: https://rubygems.org/\n specs:\n \ + colorize (1.1.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n \ + colorize (= 1.1.0)\n\nBUNDLED WITH\n 1.17.3\n"; + + /// [P1 bundler floor — refuse side] Bundler 1.x cannot load the + /// `plugin ... path:` directive `setup` writes (Plugin::DSL undef's + /// `:path`; the 1.x plugin installer knows only git/rubygems sources), so + /// a wired project dies on EVERY later `bundle install` with exit 7 + /// ("Could not find gem 'socket-patch' ...") before plugin registration — + /// an error that never names socket-patch (campaign-confirmed on + /// 1.17.3). `setup` must refuse to wire such a project, loudly, and leave + /// the Gemfile untouched so `bundle install` keeps working. + #[test] + fn gem_setup_refuses_bundler_1x_locked_project() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("Gemfile"), GEMFILE).unwrap(); + std::fs::write(root.join("Gemfile.lock"), LOCK_1X).unwrap(); + let root_s = root.to_str().unwrap(); + + let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); + assert_eq!( + code, 1, + "setup on a bundler-1.x-locked project must refuse (exit 1), not \ + wire an install-breaking directive.\n{out}\n{err}" + ); + let v = parse_json(&out, "setup (bundler 1.x)"); + assert_eq!( + json_str(&v, "status", "setup (bundler 1.x)"), + "error", + "the refusal must be an error status:\n{v}" + ); + // The refusal names the problem: the detected bundler and the floor. + let files = v.get("files").and_then(|f| f.as_array()).expect("files[]"); + let gem_err = files + .iter() + .filter(|f| f.get("kind").and_then(|k| k.as_str()) == Some("gemfile")) + .find_map(|f| f.get("error").and_then(|e| e.as_str())) + .unwrap_or_else(|| panic!("no gemfile error entry in files[]:\n{v}")); + assert!( + gem_err.contains("1.17.3") && gem_err.contains("2.2"), + "the error must name the detected bundler and the >= 2.2 floor:\n{gem_err}" + ); + // On disk: nothing was wired. + assert_eq!( + gemfile_body(root), + GEMFILE, + "the Gemfile must be byte-untouched after the refusal" + ); + assert!( + !root.join(PLUGIN_DIR).exists(), + "no plugin dir may be generated for a refused project" + ); + } + + /// [P1 bundler floor — check side] The campaign's worst variant: a + /// project wired elsewhere (older CLI, or a machine with bundler >= 2.2) + /// whose lock pins bundler 1.x. Every `bundle install` fails with exit 7, + /// yet `setup --check` said "configured" — the CI gate the check exists + /// to be went green while installs were broken. `--check` must red-flag + /// the wired-but-unloadable state and name the remedy. + #[test] + fn gem_check_red_flags_wired_but_unloadable_bundler_1x() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("Gemfile"), GEMFILE).unwrap(); + let root_s = root.to_str().unwrap(); + + // Wire for real with the actual CLI (no 1.x lock yet, so the host — + // bundler >= 2.2 or none — lets setup proceed) ... + let (code, out, err) = run(root, &["setup", "--cwd", root_s, "--yes", "--json"]); + assert_eq!(code, 0, "precondition: wiring must succeed.\n{out}\n{err}"); + assert!(gemfile_body(root).contains(MANAGED_MARKER)); + + // ... then the 1.x lock arrives (clone of a legacy project / CI image + // downgrade). This is the state the campaign reproduced. + std::fs::write(root.join("Gemfile.lock"), LOCK_1X).unwrap(); + + let (code, out, err) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); + assert_eq!( + code, 1, + "check must FAIL on a wired project whose bundler cannot load the \ + plugin — this exact state broke every `bundle install` while \ + check reported configured.\n{out}\n{err}" + ); + let v = parse_json(&out, "check (wired, bundler 1.x)"); + assert_eq!( + json_str(&v, "status", "check (wired, bundler 1.x)"), + "error", + "the unloadable wiring is an error, not a plain needs_configuration:\n{v}" + ); + let files = v.get("files").and_then(|f| f.as_array()).expect("files[]"); + let gem_err = files + .iter() + .filter(|f| f.get("kind").and_then(|k| k.as_str()) == Some("gemfile")) + .find_map(|f| f.get("error").and_then(|e| e.as_str())) + .unwrap_or_else(|| panic!("no gemfile error entry in files[]:\n{v}")); + assert!( + gem_err.contains("1.17.3") + && gem_err.contains("2.2") + && gem_err.contains("--remove"), + "the check error must name the detected bundler, the floor, and \ + the `setup --remove` recovery:\n{gem_err}" + ); + + // Recovery: `setup --remove` still un-wires (the gate must never + // block it), restoring the Gemfile byte-for-byte. + let (code, out, err) = run( + root, + &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], + ); + assert_eq!(code, 0, "recovery remove must work.\n{out}\n{err}"); + assert_eq!(gemfile_body(root), GEMFILE, "Gemfile restored"); + assert!(!root.join(PLUGIN_DIR).exists(), "plugin dir removed"); + } + /// `bundle` resolves the Gemfile by walking UP from the invocation dir, /// and `discover_bundler_project` documents the same contract. Run from a /// subdirectory with NO `--cwd` flag the CLI defaults to the RELATIVE diff --git a/crates/socket-patch-core/src/setup/gem/update.rs b/crates/socket-patch-core/src/setup/gem/update.rs index 18c6f210..5bda88ad 100644 --- a/crates/socket-patch-core/src/setup/gem/update.rs +++ b/crates/socket-patch-core/src/setup/gem/update.rs @@ -714,6 +714,120 @@ mod tests { ); } + // ── bundler version floor ───────────────────────────────────────── + // + // Bundler 1.x cannot load a `plugin ... path:` directive: `Plugin::DSL` + // undef_methods `:path` and the 1.x plugin installer supports only + // git/rubygems sources, so the directive is resolved as an ORDINARY GEM + // and every later `bundle install` dies with exit 7 ("Could not find gem + // 'socket-patch' ...") BEFORE plugin registration — an error that never + // names socket-patch. Wiring such a project is strictly worse than + // refusing (reproduced on bundler 1.17.3). The project's bundler is read + // from the lock's `BUNDLED WITH` section — deterministic, and present + // even where `bundle` is not on PATH. + + const LOCK_1X: &str = "GEM\n remote: https://rubygems.org/\n specs:\n \ + colorize (1.1.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n \ + colorize (= 1.1.0)\n\nBUNDLED WITH\n 1.17.3\n"; + + #[tokio::test] + async fn test_add_refuses_bundler_1x_locked_project() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Gemfile"), GEMFILE).await.unwrap(); + fs::write(root.join("Gemfile.lock"), LOCK_1X).await.unwrap(); + let project = super::super::discover_bundler_project(root).await.unwrap(); + + let results = add_plugin_directive(&project, false).await; + + assert!( + results.iter().any(|r| r.status == GemSetupStatus::Error), + "wiring a bundler-1.x project must be refused as an error: {results:?}" + ); + let msg = results + .iter() + .find_map(|r| r.error.as_deref()) + .unwrap_or_default(); + assert!( + msg.contains("1.17.3") && msg.contains("2.2"), + "the refusal must name the detected bundler and the floor: {msg:?}" + ); + assert_eq!( + fs::read_to_string(root.join("Gemfile")).await.unwrap(), + GEMFILE, + "the Gemfile must NOT be wired — bundler 1.x resolves the plugin \ + directive as an ordinary gem and every later `bundle install` \ + exits 7" + ); + assert!( + !super::super::plugin_files_present(root).await, + "no plugin files may be generated for a refused project" + ); + } + + #[tokio::test] + async fn test_add_dry_run_also_refuses_bundler_1x() { + // The preview must refuse identically — a dry-run that previews the + // wiring while the real run errors would lie to the user. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Gemfile"), GEMFILE).await.unwrap(); + fs::write(root.join("Gemfile.lock"), LOCK_1X).await.unwrap(); + let project = super::super::discover_bundler_project(root).await.unwrap(); + + let results = add_plugin_directive(&project, true).await; + assert!( + results.iter().any(|r| r.status == GemSetupStatus::Error), + "dry-run must surface the same refusal: {results:?}" + ); + } + + #[tokio::test] + async fn test_add_proceeds_on_bundler_2x_lock() { + // A supported lock must not trip the gate. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Gemfile"), GEMFILE).await.unwrap(); + fs::write( + root.join("Gemfile.lock"), + LOCK_1X.replace("1.17.3", "2.7.2"), + ) + .await + .unwrap(); + let project = super::super::discover_bundler_project(root).await.unwrap(); + + let results = add_plugin_directive(&project, false).await; + assert!( + results.iter().all(|r| r.status == GemSetupStatus::Updated), + "a bundler-2.x lock must wire normally: {results:?}" + ); + } + + #[tokio::test] + async fn test_remove_still_unwires_bundler_1x_project() { + // `setup --remove` is the RECOVERY path for a project wired before + // the floor existed (or wired on another machine) — the gate must + // never block the un-wire. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Gemfile"), gemfile_add(GEMFILE).unwrap()) + .await + .unwrap(); + fs::write(root.join("Gemfile.lock"), LOCK_1X).await.unwrap(); + let project = super::super::discover_bundler_project(root).await.unwrap(); + + let results = remove_plugin_directive(&project, false).await; + assert!( + results.iter().all(|r| r.status != GemSetupStatus::Error), + "remove must not be blocked by the version gate: {results:?}" + ); + assert_eq!( + fs::read_to_string(root.join("Gemfile")).await.unwrap(), + GEMFILE, + "the recovery un-wire restores the Gemfile byte-for-byte" + ); + } + #[tokio::test] async fn test_full_roundtrip_via_project() { let dir = tempfile::tempdir().unwrap(); From 1d21fd4387bf63e41ad828d5f75fd934a6d86d29 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 18 Aug 2026 21:06:43 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(gem):=20bundler=20>=3D=202.2=20floor=20?= =?UTF-8?q?=E2=80=94=20setup=20refuses=20to=20wire,=20check=20red-flags=20?= =?UTF-8?q?unloadable=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New core probe (setup/gem/version.rs): read the project's bundler from the lock's BUNDLED WITH (Gemfile.lock / gems.locked — deterministic, works where bundle is not on PATH, and the version RubyGems' switching actually runs), falling back to `bundle --version` (bundler 4's bare output parses too); fail OPEN when neither yields a version. - add_plugin_directive (dry-run included) refuses below the floor with an error naming the detected version, the >= 2.2 floor, and the upgrade remedy; remove_plugin_directive stays ungated (recovery path) - setup --check red-flags both unsupported states as errors: wired-but- unloadable (names `setup --remove`) and unwired-unwireable Matches the gem branch's own error-channel precedent (missing Gemfile, unwritable plugin dir): where setup cannot deliver a WORKING configuration it errors loudly rather than narrowing silently. Co-Authored-By: Claude Fable 5 --- crates/socket-patch-cli/src/commands/setup.rs | 33 +- crates/socket-patch-core/src/setup/gem/mod.rs | 2 + .../socket-patch-core/src/setup/gem/update.rs | 17 + .../src/setup/gem/version.rs | 296 ++++++++++++++++++ 4 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 crates/socket-patch-core/src/setup/gem/version.rs diff --git a/crates/socket-patch-cli/src/commands/setup.rs b/crates/socket-patch-cli/src/commands/setup.rs index 53b14d52..9a2faf53 100644 --- a/crates/socket-patch-cli/src/commands/setup.rs +++ b/crates/socket-patch-cli/src/commands/setup.rs @@ -825,12 +825,37 @@ async fn append_gem_check_entries( Some(p) => p, None => return false, }; + // The bundler version floor (core `setup::gem::version`): bundler 1.x + // cannot load the `plugin ... path:` directive, so a WIRED project fails + // every `bundle install` (exit 7, an error that never names socket-patch) + // — the campaign-confirmed state where `--check` kept saying "configured" + // while the CI gate it exists to be went green over broken installs. Both + // wired and unwired unsupported projects are red-flagged as errors: setup + // itself refuses to wire below the floor, so "needs_configuration" (run + // `setup` to fix) would point at a command that cannot help. + let probe = gem::probe_bundler(&project).await; let (state, err) = match tokio::fs::read_to_string(&project.gemfile).await { Ok(content) => { - if gem::is_plugin_directive_present(&content) { - (CheckState::Configured, None) - } else { - (CheckState::NeedsConfiguration, None) + let wired = gem::is_plugin_directive_present(&content); + match (&probe, wired) { + (gem::BundlerProbe::Unsupported { version, source }, true) => ( + CheckState::Error, + Some(format!( + "the wired socket-patch plugin cannot load under bundler \ + {version} (from {source}; needs >= {}.{}): every `bundle \ + install` fails resolving 'socket-patch' as an ordinary gem \ + (exit 7) before the plugin registers. Run `socket-patch \ + setup --remove` to unwire, or upgrade bundler", + gem::MIN_BUNDLER.0, + gem::MIN_BUNDLER.1 + )), + ), + (gem::BundlerProbe::Unsupported { version, source }, false) => ( + CheckState::Error, + Some(gem::unsupported_bundler_message(version, source)), + ), + (_, true) => (CheckState::Configured, None), + (_, false) => (CheckState::NeedsConfiguration, None), } } Err(e) => (CheckState::Error, Some(e.to_string())), diff --git a/crates/socket-patch-core/src/setup/gem/mod.rs b/crates/socket-patch-core/src/setup/gem/mod.rs index 871aa399..31c8d8c9 100644 --- a/crates/socket-patch-core/src/setup/gem/mod.rs +++ b/crates/socket-patch-core/src/setup/gem/mod.rs @@ -33,6 +33,7 @@ //! with a published `socket-patch-bundler` gem. mod update; +mod version; use std::path::{Path, PathBuf}; @@ -42,6 +43,7 @@ pub use update::{ add_plugin_directive, is_plugin_directive_present, remove_plugin_directive, GemEditResult, GemSetupStatus, }; +pub use version::{probe_bundler, unsupported_bundler_message, BundlerProbe, MIN_BUNDLER}; /// The in-tree plugin directory, relative to the project root. const PLUGIN_DIR: &str = ".socket/bundler-plugin"; diff --git a/crates/socket-patch-core/src/setup/gem/update.rs b/crates/socket-patch-core/src/setup/gem/update.rs index 5bda88ad..ab7c65c4 100644 --- a/crates/socket-patch-core/src/setup/gem/update.rs +++ b/crates/socket-patch-core/src/setup/gem/update.rs @@ -10,6 +10,7 @@ use std::path::Path; use tokio::fs; +use super::version::{probe_bundler, unsupported_bundler_message, BundlerProbe}; use super::{add_plugin_files, remove_plugin_files, BundlerProject}; use crate::utils::fs::atomic_write_bytes_preserving_mode; @@ -221,7 +222,23 @@ async fn edit_gemfile_remove(gemfile: &Path, dry_run: bool) -> GemEditResult { /// Wiring first and then failing to write the files would leave the project /// unable to install at all — strictly worse than never having run `setup`. /// Wiring last keeps a failure's blast radius at "not configured". +/// +/// Refused outright — dry-run included, so the preview never promises a wire +/// the real run would reject — when the project's bundler is below the +/// [`super::MIN_BUNDLER`] floor: bundler 1.x resolves the `plugin ... path:` +/// directive as an ordinary gem and every later `bundle install` exits 7 +/// before the plugin registers (see `version.rs`). The probe fails OPEN on +/// an undetectable version; `remove_plugin_directive` is never gated (it is +/// the recovery path for an already-wired 1.x project). pub async fn add_plugin_directive(project: &BundlerProject, dry_run: bool) -> Vec { + if let BundlerProbe::Unsupported { version, source } = probe_bundler(project).await { + return vec![GemEditResult { + kind: "gemfile", + path: project.gemfile.display().to_string(), + status: GemSetupStatus::Error, + error: Some(unsupported_bundler_message(&version, &source)), + }]; + } let files = add_plugin_files(&project.root, dry_run).await; if files.status == GemSetupStatus::Error { return vec![files]; diff --git a/crates/socket-patch-core/src/setup/gem/version.rs b/crates/socket-patch-core/src/setup/gem/version.rs new file mode 100644 index 00000000..45b9cfc1 --- /dev/null +++ b/crates/socket-patch-core/src/setup/gem/version.rs @@ -0,0 +1,296 @@ +//! Bundler version floor for the generated plugin wiring. +//! +//! The `plugin "socket-patch", path: ...` directive `setup` writes needs +//! bundler >= 2.2. Bundler 1.x cannot load it: `Plugin::DSL` undef_methods +//! `:path` and the 1.x plugin installer supports only git/rubygems sources, +//! so the directive is resolved as an ORDINARY GEM and every later +//! `bundle install` dies with exit 7 ("Could not find gem 'socket-patch' +//! ...") BEFORE plugin registration — an error that never names +//! socket-patch, and in deployment mode adds a misleading "Perhaps the +//! lockfile is corrupted?" line (reproduced on bundler 1.17.3). Wiring such +//! a project is strictly worse than refusing. +//! +//! The probe reads, in order: +//! 1. the lock's `BUNDLED WITH` section (`Gemfile.lock`, or `gems.locked` +//! for a `gems.rb` project) — deterministic, present even where +//! `bundle` is not on PATH, and the best predictor of the bundler that +//! will actually run installs (RubyGems' version switching selects the +//! locked bundler when installed; bundler >= 2.3 auto-installs it); +//! 2. `bundle --version` in the project root — the machine's bundler, +//! for lock-less projects. Bundler 4 dropped the "Bundler version " +//! prefix and prints the bare version, so both spellings parse. +//! +//! When NEITHER source yields a version the probe reports [`BundlerProbe:: +//! Unknown`] and callers fail OPEN (wire as before): a machine without +//! bundler may be preparing a repo whose CI has a modern bundler, and +//! refusing there would block every such setup on a guess. + +use std::path::PathBuf; + +use tokio::fs; + +use super::BundlerProject; + +/// Minimum bundler `(major, minor)` able to load a `plugin ... path:` +/// directive. +pub const MIN_BUNDLER: (u64, u64) = (2, 2); + +/// Outcome of probing the project's bundler version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BundlerProbe { + /// A version at or above [`MIN_BUNDLER`] was detected. + Supported, + /// A version below [`MIN_BUNDLER`] was detected. `version` is the + /// detected version string; `source` names where it was read from + /// (for the refusal message). + Unsupported { version: String, source: String }, + /// No version could be determined (no lock, no `bundle` on PATH, or + /// unparseable output). Callers fail open. + Unknown, +} + +/// The lockfile paired with the project's manifest name: `gems.rb` locks to +/// `gems.locked`, `Gemfile` to `Gemfile.lock` (Bundler's own pairing). +fn lockfile_path(project: &BundlerProject) -> PathBuf { + let lock_name = match project.gemfile.file_name().and_then(|n| n.to_str()) { + Some("gems.rb") => "gems.locked", + _ => "Gemfile.lock", + }; + project.root.join(lock_name) +} + +/// Extract the version under a lock's `BUNDLED WITH` section: the first +/// non-empty line after the header, trimmed. +fn parse_bundled_with(lock: &str) -> Option { + let mut lines = lock.lines(); + while let Some(line) = lines.next() { + if line.trim() != "BUNDLED WITH" { + continue; + } + for candidate in lines.by_ref() { + let candidate = candidate.trim(); + if !candidate.is_empty() { + return looks_like_version(candidate).then(|| candidate.to_string()); + } + } + return None; + } + None +} + +/// Extract a version from `bundle --version` output. Bundler <= 3 prints +/// "Bundler version 2.7.2"; bundler 4 prints the bare "4.0.18". Take the +/// first whitespace token that parses as a dotted version. +fn parse_bundle_version_output(out: &str) -> Option { + out.split_whitespace() + .find(|tok| looks_like_version(tok)) + .map(str::to_string) +} + +/// A token counts as a version when it is `digits.digits[...]` — enough to +/// reject prose without a full semver parser. +fn looks_like_version(tok: &str) -> bool { + let mut parts = tok.split('.'); + let (Some(major), Some(minor)) = (parts.next(), parts.next()) else { + return false; + }; + !major.is_empty() + && major.bytes().all(|b| b.is_ascii_digit()) + && !minor.is_empty() + && minor.bytes().all(|b| b.is_ascii_digit()) +} + +/// Whether `version` (a `major.minor[...]` string) meets [`MIN_BUNDLER`]. +/// `None` when the leading components don't parse. +fn meets_floor(version: &str) -> Option { + let mut parts = version.split('.'); + let major: u64 = parts.next()?.parse().ok()?; + let minor: u64 = parts.next()?.parse().ok()?; + Some((major, minor) >= MIN_BUNDLER) +} + +/// Classify one detected `(version, source)` pair. +fn classify(version: String, source: String) -> BundlerProbe { + match meets_floor(&version) { + Some(true) => BundlerProbe::Supported, + Some(false) => BundlerProbe::Unsupported { version, source }, + // Unparseable leading components: treat as unknown, fail open. + None => BundlerProbe::Unknown, + } +} + +/// Probe the bundler version that will run this project's installs. See the +/// module docs for the source order and the fail-open contract. +pub async fn probe_bundler(project: &BundlerProject) -> BundlerProbe { + let lock_path = lockfile_path(project); + if let Ok(lock) = fs::read_to_string(&lock_path).await { + if let Some(version) = parse_bundled_with(&lock) { + let lock_name = lock_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Gemfile.lock".to_string()); + return classify(version, format!("{lock_name} BUNDLED WITH")); + } + } + // No lock (or no BUNDLED WITH): ask the machine's bundler. stdin nulled + // so the child can never block waiting for input. + let output = tokio::process::Command::new("bundle") + .arg("--version") + .current_dir(&project.root) + .stdin(std::process::Stdio::null()) + .output() + .await; + if let Ok(out) = output { + if out.status.success() { + if let Some(version) = + parse_bundle_version_output(&String::from_utf8_lossy(&out.stdout)) + { + return classify(version, "`bundle --version`".to_string()); + } + } + } + BundlerProbe::Unknown +} + +/// The refusal message for an [`BundlerProbe::Unsupported`] project — shared +/// by `setup` (which refuses to wire) so the wording stays consistent. +pub fn unsupported_bundler_message(version: &str, source: &str) -> String { + format!( + "bundler {version} (from {source}) cannot load the socket-patch Bundler \ + plugin: the `plugin ... path:` directive needs bundler >= {}.{}, and on \ + 1.x every later `bundle install` fails resolving 'socket-patch' as an \ + ordinary gem (exit 7) before the plugin registers. Not wiring this \ + project. Upgrade bundler (`gem install bundler`, then `bundle update \ + --bundler`) and re-run `socket-patch setup`", + MIN_BUNDLER.0, MIN_BUNDLER.1 + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const LOCK_1X: &str = "GEM\n remote: https://rubygems.org/\n specs:\n \ + colorize (1.1.0)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n \ + colorize (= 1.1.0)\n\nBUNDLED WITH\n 1.17.3\n"; + + async fn project_with(files: &[(&str, &str)]) -> (tempfile::TempDir, BundlerProject) { + let dir = tempfile::tempdir().unwrap(); + for (name, body) in files { + fs::write(dir.path().join(name), body).await.unwrap(); + } + let project = super::super::discover_bundler_project(dir.path()) + .await + .expect("fixture project must be discoverable"); + (dir, project) + } + + #[test] + fn test_parse_bundled_with() { + assert_eq!(parse_bundled_with(LOCK_1X).as_deref(), Some("1.17.3")); + assert_eq!( + parse_bundled_with("BUNDLED WITH\n 2.7.2\n").as_deref(), + Some("2.7.2") + ); + // No section, or a section followed by garbage → None. + assert_eq!(parse_bundled_with("GEM\n specs:\n"), None); + assert_eq!(parse_bundled_with("BUNDLED WITH\n not-a-version\n"), None); + assert_eq!(parse_bundled_with("BUNDLED WITH\n"), None); + } + + #[test] + fn test_parse_bundle_version_output_both_spellings() { + // bundler <= 3 prefix form and bundler 4's bare form. + assert_eq!( + parse_bundle_version_output("Bundler version 2.7.2\n").as_deref(), + Some("2.7.2") + ); + assert_eq!( + parse_bundle_version_output("4.0.18\n").as_deref(), + Some("4.0.18") + ); + assert_eq!(parse_bundle_version_output("command not found\n"), None); + } + + #[test] + fn test_meets_floor_boundaries() { + assert_eq!(meets_floor("1.17.3"), Some(false)); + assert_eq!(meets_floor("2.1.4"), Some(false)); + assert_eq!(meets_floor("2.2.0"), Some(true)); + assert_eq!(meets_floor("2.7.2"), Some(true)); + assert_eq!(meets_floor("4.0.18"), Some(true)); + // Bare major (no minor) or garbage: unknown, never a refusal. + assert_eq!(meets_floor("2"), None); + assert_eq!(meets_floor("abc"), None); + } + + #[tokio::test] + async fn test_probe_reads_gemfile_lock_bundled_with() { + let (_dir, project) = + project_with(&[("Gemfile", "source 'x'\n"), ("Gemfile.lock", LOCK_1X)]).await; + assert_eq!( + probe_bundler(&project).await, + BundlerProbe::Unsupported { + version: "1.17.3".to_string(), + source: "Gemfile.lock BUNDLED WITH".to_string(), + } + ); + } + + #[tokio::test] + async fn test_probe_supported_lock() { + let (_dir, project) = project_with(&[ + ("Gemfile", "source 'x'\n"), + ("Gemfile.lock", "BUNDLED WITH\n 2.7.2\n"), + ]) + .await; + assert_eq!(probe_bundler(&project).await, BundlerProbe::Supported); + } + + #[tokio::test] + async fn test_probe_gems_rb_pairs_with_gems_locked() { + // A gems.rb project locks to gems.locked — a stray Gemfile.lock (from + // before a rename) must NOT be consulted for it. + let (_dir, project) = project_with(&[ + ("gems.rb", "source 'x'\n"), + ("gems.locked", LOCK_1X), + ("Gemfile.lock", "BUNDLED WITH\n 2.7.2\n"), + ]) + .await; + assert_eq!( + probe_bundler(&project).await, + BundlerProbe::Unsupported { + version: "1.17.3".to_string(), + source: "gems.locked BUNDLED WITH".to_string(), + } + ); + } + + #[tokio::test] + async fn test_probe_lock_beats_machine_bundler() { + // With a lock present the machine's `bundle --version` is never + // consulted: RubyGems' version switching makes the LOCKED bundler the + // one that runs installs. (This also keeps the probe deterministic on + // hosts whose bundler differs from the project's.) + let (_dir, project) = project_with(&[ + ("Gemfile", "source 'x'\n"), + ("Gemfile.lock", "BUNDLED WITH\n 1.17.3\n"), + ]) + .await; + // Host bundler (if any) is >= 2.x on every dev/CI machine this suite + // runs on; the probe must still report the lock's 1.17.3. + assert!(matches!( + probe_bundler(&project).await, + BundlerProbe::Unsupported { .. } + )); + } + + #[test] + fn test_unsupported_message_names_version_floor_and_remedy() { + let msg = unsupported_bundler_message("1.17.3", "Gemfile.lock BUNDLED WITH"); + assert!(msg.contains("1.17.3")); + assert!(msg.contains(">= 2.2")); + assert!(msg.contains("gem install bundler")); + assert!(msg.contains("socket-patch setup")); + } +} From a8666779dc3e7a9ea9373fd1953808755718b21b Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 18 Aug 2026 21:13:18 -0400 Subject: [PATCH 3/4] test(gem)+docs: bundler-1.17 docker floor leg, gem-b1/gem-b4 matrix images, floor docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/docker/Dockerfile.gem-b1 (ruby 3.1 + bundler 1.17.3) and Dockerfile.gem-b4 (ruby 3.4 + bundler ~> 4.0) from the campaign's bundler-version matrix, with a BASE_IMAGE build-arg so a binary under test can be baked without touching shared :latest tags. Not built in CI (the CI setup-matrix job drives scripts/setup-matrix.sh against the plain gem image only) — local build lines documented in the headers and tests/docker/README.md. - setup_matrix_gem.rs: bundler_floor_docker leg (soft-skip when docker/ image absent; SOCKET_PATCH_GEM_B1_IMAGE override) driving real bundler 1.17.3: setup refuses via the `bundle --version` fallback, `bundle install` keeps working, the lock path refuses too, and --check red-flags. Verified: fails against a pre-fix image (SETUP-RC=0), passes against a fix-baked one. - README.md + docs/ecosystems.md: document the bundler >= 2.2 floor. Co-Authored-By: Claude Fable 5 --- README.md | 5 +- .../tests/setup_matrix_gem.rs | 139 ++++++++++++++++++ docs/ecosystems.md | 2 +- tests/docker/Dockerfile.gem-b1 | 46 ++++++ tests/docker/Dockerfile.gem-b4 | 45 ++++++ tests/docker/README.md | 31 ++++ 6 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 tests/docker/Dockerfile.gem-b1 create mode 100644 tests/docker/Dockerfile.gem-b4 diff --git a/README.md b/README.md index 8ddaeddc..a8c2c14b 100644 --- a/README.md +++ b/README.md @@ -724,7 +724,10 @@ What gets wired, per ecosystem: - **RubyGems (Bundler)** — adds a managed `plugin "socket-patch"` block to the `Gemfile` and generates an in-tree Bundler plugin under `.socket/bundler-plugin/`. It re-applies patches on every `bundle install` (cached *and* fresh). (Requires the `socket-patch` - CLI on `PATH`.) + CLI on `PATH`, and **bundler >= 2.2**: bundler 1.x cannot load a `plugin ... path:` + directive — it resolves it as an ordinary gem and every later `bundle install` fails — + so `setup` refuses to wire a project whose lock or `bundle --version` reports an older + bundler, and `setup --check` red-flags a wired project that lands in that state.) - **Composer (PHP)** — appends `socket-patch apply` to `composer.json`'s `post-install-cmd` / `post-update-cmd` script events, so patches re-apply on every `composer install` / `composer update`. (Requires the `socket-patch` CLI on `PATH`.) diff --git a/crates/socket-patch-cli/tests/setup_matrix_gem.rs b/crates/socket-patch-cli/tests/setup_matrix_gem.rs index 570a0af2..dac9eeaa 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_gem.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_gem.rs @@ -540,6 +540,145 @@ mod host_guard { } } +// ───────────────────────────────────────────────────────────────────────── +// Bundler-1.17 docker leg for the >= 2.2 plugin floor. +// +// The host guards above pin the LOCK-probe path (`BUNDLED WITH 1.17.3`) +// against the workspace binary; this leg drives a real bundler 1.17.3 +// toolchain (image: tests/docker/Dockerfile.gem-b1) end to end, covering the +// `bundle --version` fallback path too, and proves the property the floor +// exists for: after `setup` refuses, `bundle install` KEEPS WORKING. Before +// the floor, setup wired `plugin 'socket-patch', path: ...` and bundler 1.17 +// (whose Plugin::DSL undef's `:path`) resolved it as an ordinary gem — +// every later install exited 7 with an error that never named socket-patch +// (campaign-confirmed). +// +// Soft-skips loudly when Docker / the image is absent, mirroring the +// docker_e2e_* convention. NOTE: the leg runs the socket-patch binary BAKED +// INTO the image — an image built before this fix still wires the project +// and fails this leg deterministically; rebuild it (see the Dockerfile +// header), or point SOCKET_PATCH_GEM_B1_IMAGE at a fresh uniquely-tagged +// build. +// ───────────────────────────────────────────────────────────────────────── +mod bundler_floor_docker { + use std::process::Command; + + fn image() -> String { + std::env::var("SOCKET_PATCH_GEM_B1_IMAGE") + .unwrap_or_else(|_| "socket-patch-test-gem-b1:latest".to_string()) + } + + /// Returns `true` when the leg should skip (docker or the image missing). + /// Prints a skip notice — Rust tests have no native "skipped" outcome. + /// Build locally with + /// `docker build -f tests/docker/Dockerfile.gem-b1 -t socket-patch-test-gem-b1:latest .` + #[must_use] + fn skip_if_no_image(image: &str) -> bool { + let Ok(out) = Command::new("docker") + .args(["image", "inspect", image]) + .output() + else { + eprintln!("skipping: `docker` not on PATH"); + return true; + }; + if !out.status.success() { + eprintln!("skipping: docker image `{image}` not present"); + return true; + } + false + } + + /// The whole flow runs INSIDE the container (no bind mounts, no network): + /// scaffold → setup refuses (version fallback path, no lock yet) → + /// `bundle install` still works and writes the 1.17 lock → setup refuses + /// again (lock path) → `setup --check` red-flags. Host-side assertions + /// read the markers + JSON the script echoes. + #[test] + fn bundler_1x_setup_refuses_and_installs_keep_working() { + let image = image(); + if skip_if_no_image(&image) { + return; + } + let script = r#" +set -eu +export SOCKET_NO_CONFIG=1 SOCKET_TELEMETRY_DISABLED=1 +mkdir -p /workspace/proj && cd /workspace/proj +printf '# no dependencies\n' > Gemfile +cp Gemfile /tmp/gemfile-pre + +# 1) No lock yet: the probe falls back to `bundle --version` (1.17.3). +set +e +socket-patch setup --yes --json --ecosystems gem > setup.json 2> setup.err +rc=$? +set -e +echo "SETUP-RC=$rc" +cat setup.json +if grep -q 'socket-patch:managed' Gemfile; then echo 'WIRED-BUT-MUST-NOT'; exit 1; fi +if [ -e .socket/bundler-plugin ]; then echo 'PLUGIN-DIR-BUT-MUST-NOT'; exit 1; fi +cmp -s Gemfile /tmp/gemfile-pre && echo 'GEMFILE-UNTOUCHED' + +# 2) The refused project still installs (writes the 1.17 lock). +bundle install > install.log 2>&1 || { echo 'INSTALL-BROKE'; cat install.log; exit 1; } +echo 'INSTALL-OK' +grep -q '1\.17\.3' Gemfile.lock && echo 'LOCK-1X' + +# 3) Lock present: the deterministic BUNDLED WITH path refuses too. +set +e +socket-patch setup --yes --json --ecosystems gem > setup2.json 2>&1 +rc2=$? +set -e +echo "SETUP2-RC=$rc2" + +# 4) check red-flags the unsupported state. +set +e +socket-patch setup --check --json --ecosystems gem > check.json 2>&1 +rc3=$? +set -e +echo "CHECK-RC=$rc3" +cat check.json +echo 'ALL-DONE' +"#; + let out = Command::new("docker") + .args(["run", "--rm", "--network", "none", &image, "bash", "-c", script]) + .output() + .expect("docker run"); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + let ctx = format!("stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("ALL-DONE"), + "the in-container flow aborted early (a refused project whose \ + `bundle install` breaks is the campaign defect).\n{ctx}" + ); + assert!( + stdout.contains("SETUP-RC=1"), + "setup must refuse (exit 1) under bundler 1.17.3.\n{ctx}" + ); + // The refusal names the detected bundler and the floor (setup.json is + // echoed into stdout). + assert!( + stdout.contains("1.17.3") && stdout.contains("2.2"), + "the refusal must name bundler 1.17.3 and the >= 2.2 floor.\n{ctx}" + ); + assert!( + stdout.contains("GEMFILE-UNTOUCHED"), + "the Gemfile must be byte-untouched after the refusal.\n{ctx}" + ); + assert!( + stdout.contains("INSTALL-OK") && stdout.contains("LOCK-1X"), + "`bundle install` must keep working after the refusal.\n{ctx}" + ); + assert!( + stdout.contains("SETUP2-RC=1"), + "the lock-probe path must refuse as well.\n{ctx}" + ); + assert!( + stdout.contains("CHECK-RC=1") && stdout.contains("\"status\": \"error\""), + "`setup --check` must red-flag the unsupported bundler.\n{ctx}" + ); + } +} + // ───────────────────────────────────────────────────────────────────────── // Runtime guards for the GENERATED plugin, driven through a REAL `bundle // install` (host bundler; validated against 4.0.15, and the same flows diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 7733e1bd..aef663c9 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 | ✅ any install layout; `setup` postinstall hook | ✅ five lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, bun `bun.lock` (binary `bun.lockb` refused with a `--save-text-lockfile` pointer). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml (pnpm v9), yarn classic, yarn berry, bun — pnpm, berry, and bun carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ `.pth` startup hook via `setup` | ✅ five lockfile flavors: uv, poetry, pdm, pipenv (lock rewired, but pipenv doesn't hash-check file entries — `vendor_integrity_unverified` warning; the committed wheel bytes are the protection), and requirements.txt (consumed by pip or `uv pip`) | ✅ requirements.txt + uv.lock. **poetry / pdm / pipenv locks are not rewritten** — use vendored | | 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 | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum) | -| RubyGems (`gem`) | ✅ Bundler plugin via `setup` | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ 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`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning) | +| RubyGems (`gem`) | ✅ Bundler plugin via `setup` — needs bundler ≥ 2.2 (1.x cannot load `plugin ... path:` directives; `setup` refuses below the floor and `setup --check` red-flags a wired 1.x project) | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ 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`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning) | | 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 [golang-hosted.md](design/golang-hosted.md). Paid tier stays ❌ ([golang-hosted-no-go.md](design/golang-hosted-no-go.md)); `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) | ✅ apply-only (no `setup` hook — reports `no_files`); in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed maven2 `file://` repository. A root pom declaring `` (multi-module aggregator) is refused (`vendor_maven_multimodule_unsupported`), and a gradle-only project is refused (`vendor_gradle_unsupported`) | ✅ **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`) | ✅ apply-only (no `setup` hook — reports `no_files`); 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) | diff --git a/tests/docker/Dockerfile.gem-b1 b/tests/docker/Dockerfile.gem-b1 new file mode 100644 index 00000000..748070cf --- /dev/null +++ b/tests/docker/Dockerfile.gem-b1 @@ -0,0 +1,46 @@ +# gem (Ruby) bundler-1 matrix image: Ruby 3.1 + bundler 1.17.3 + socket-patch. +# +# Bundler 1.17.3 is the last 1.x release. It calls `untaint`, which Ruby 3.2 +# removed, so this image pins ruby:3.1 (the newest Ruby that still carries the +# method as a no-op) on bookworm so the socket-patch binary compiled against +# the base image's glibc still runs. Ruby 3.1 ships a default bundler 2.3.x; +# BUNDLER_VERSION forces the binstub to select 1.17.3 in every process. +# +# webrick is baked in (removed from Ruby stdlib in 3.0) so matrix probe +# scripts can run an in-container loopback mock server under `--network none`. +# +# Used by the bundler-version legs in setup_matrix_gem.rs (the >= 2.2 plugin +# floor: bundler 1.x cannot load `plugin ... path:` directives). These legs +# do NOT run in CI (the CI setup-matrix job drives scripts/setup-matrix.sh +# against the plain `gem` image only) — build locally: +# +# docker build -f tests/docker/Dockerfile.base -t socket-patch-test-base:latest . +# docker build -f tests/docker/Dockerfile.gem-b1 -t socket-patch-test-gem-b1:latest . +# +# BASE_IMAGE is overridable so a fix under test can be verified without +# touching the shared :latest tags: +# docker build --build-arg BASE_IMAGE=socket-patch-test-base:mytag \ +# -f tests/docker/Dockerfile.gem-b1 -t socket-patch-test-gem-b1:mytag . +ARG BASE_IMAGE=socket-patch-test-base:latest +FROM ${BASE_IMAGE} AS sptool + +FROM ruby:3.1-slim-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* \ + && gem install bundler -v 1.17.3 --no-document \ + && gem install webrick --no-document + +ENV BUNDLER_VERSION=1.17.3 + +COPY --from=sptool /usr/local/bin/socket-patch /usr/local/bin/socket-patch +RUN ruby --version && gem --version && bundle --version \ + && bundle --version | grep -q 'Bundler version 1\.17\.3' \ + && socket-patch --version + +WORKDIR /workspace diff --git a/tests/docker/Dockerfile.gem-b4 b/tests/docker/Dockerfile.gem-b4 new file mode 100644 index 00000000..033c27e2 --- /dev/null +++ b/tests/docker/Dockerfile.gem-b4 @@ -0,0 +1,45 @@ +# gem (Ruby) bundler-4 matrix image: Ruby 3.4 + bundler ~> 4.0 + socket-patch. +# +# Bundler 4 is the current major (3 was skipped). Ruby 3.4's default bundler +# is 2.6.x; the binstub selects the newest installed version, so installing +# any 4.x makes `bundle` resolve to it without extra pinning. Bundler 4 writes +# a CHECKSUMS section into fresh locks by default — the flavor the hosted-mode +# rewrite canary in e2e_redirect_gem_build.rs pins. +# +# webrick is baked in (removed from Ruby stdlib in 3.0) so matrix probe +# scripts can run an in-container loopback mock server under `--network none`. +# +# Companion to Dockerfile.gem-b1 for bundler-version matrix runs (the plain +# `gem` image pins bundler ~> 2.7; this one covers the current major). Not +# built in CI — build locally: +# +# docker build -f tests/docker/Dockerfile.base -t socket-patch-test-base:latest . +# docker build -f tests/docker/Dockerfile.gem-b4 -t socket-patch-test-gem-b4:latest . +# +# BASE_IMAGE is overridable so a fix under test can be verified without +# touching the shared :latest tags: +# docker build --build-arg BASE_IMAGE=socket-patch-test-base:mytag \ +# -f tests/docker/Dockerfile.gem-b4 -t socket-patch-test-gem-b4:mytag . +ARG BASE_IMAGE=socket-patch-test-base:latest +FROM ${BASE_IMAGE} AS sptool + +FROM ruby:3.4-slim-bookworm + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + curl \ + git \ + && rm -rf /var/lib/apt/lists/* \ + && gem install bundler -v '~> 4.0' --no-document \ + && gem install webrick --no-document + +COPY --from=sptool /usr/local/bin/socket-patch /usr/local/bin/socket-patch +# bundler 4 dropped the "Bundler version " prefix from `bundle --version` +# (it prints the bare version), so the sanity grep matches the bare form. +RUN ruby --version && gem --version && bundle --version \ + && bundle --version | grep -qE '^(Bundler version )?4\.' \ + && socket-patch --version + +WORKDIR /workspace diff --git a/tests/docker/README.md b/tests/docker/README.md index 248819c4..feba2478 100644 --- a/tests/docker/README.md +++ b/tests/docker/README.md @@ -88,6 +88,37 @@ Debian 12's apt ruby). The gem suite runs against the default no-CHECKSUMS lock — the bundler >= 2.6 `lockfile_checksums` variant is a follow-up (see the TODO in `docker_e2e_vendor_gem.rs`). +## Bundler-version matrix images (`Dockerfile.gem-b1`, `Dockerfile.gem-b4`) + +The plain `Dockerfile.gem` pins bundler `~> 2.7`. Two sibling images cover +the ends of the bundler spectrum for the setup-matrix gem legs in +`crates/socket-patch-cli/tests/setup_matrix_gem.rs`: + +- `Dockerfile.gem-b1` — ruby 3.1 + **bundler 1.17.3** (last 1.x). Drives the + bundler `>= 2.2` plugin floor: gem `setup` must refuse to wire a 1.x + project (bundler 1.x cannot load `plugin ... path:` directives) and + `bundle install` must keep working after the refusal. +- `Dockerfile.gem-b4` — ruby 3.4 + **bundler ~> 4.0** (current major, bare + `bundle --version` output, CHECKSUMS locks by default). + +These images are NOT built in CI (the CI `setup-matrix` job drives +`scripts/setup-matrix.sh` against the plain `gem` image only). Build them +locally before running the gated legs: + +```sh +docker build -f tests/docker/Dockerfile.base -t socket-patch-test-base:latest . +docker build -f tests/docker/Dockerfile.gem-b1 -t socket-patch-test-gem-b1:latest . +docker build -f tests/docker/Dockerfile.gem-b4 -t socket-patch-test-gem-b4:latest . +cargo test -p socket-patch-cli --features setup-e2e --test setup_matrix_gem +``` + +The legs soft-skip (loudly) when Docker or the image is absent. Both +Dockerfiles take a `BASE_IMAGE` build-arg so a binary under test can be baked +in without overwriting the shared `:latest` tags; the test honors a +`SOCKET_PATCH_GEM_B1_IMAGE` env var to point at such a uniquely-tagged image. +NOTE: the legs run the binary BAKED INTO the image — rebuild base + image +after changing setup code or they test a stale binary. + ## Host mode (no Docker) Set `SOCKET_PATCH_TEST_HOST=1` to run the tests against host-installed From d43c61c2c4a688d3f7a68817466c43ce1da4e205 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Tue, 18 Aug 2026 21:26:43 -0400 Subject: [PATCH 4/4] =?UTF-8?q?fix(gem):=20reviewer=20nits=20=E2=80=94=20b?= =?UTF-8?q?undle-probe=20timeout,=20--remove=20hint=20on=20wired=201.x=20r?= =?UTF-8?q?efusal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two nits from the lane review: - probe_bundler's `bundle --version` fallback (lock-less projects only) now runs under a 10s timeout with kill_on_drop, degrading to Unknown (fail open) instead of hanging setup/setup --check on a wedged bundler. - Re-running `setup` on an ALREADY-wired bundler-1.x project appends the `socket-patch setup --remove` recovery path to the refusal message, matching what `setup --check` already names for that state. Co-Authored-By: Claude Fable 5 --- .../socket-patch-core/src/setup/gem/update.rs | 50 ++++++++++++++++++- .../src/setup/gem/version.rs | 28 ++++++++--- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/crates/socket-patch-core/src/setup/gem/update.rs b/crates/socket-patch-core/src/setup/gem/update.rs index ab7c65c4..046645ab 100644 --- a/crates/socket-patch-core/src/setup/gem/update.rs +++ b/crates/socket-patch-core/src/setup/gem/update.rs @@ -232,11 +232,23 @@ async fn edit_gemfile_remove(gemfile: &Path, dry_run: bool) -> GemEditResult { /// the recovery path for an already-wired 1.x project). pub async fn add_plugin_directive(project: &BundlerProject, dry_run: bool) -> Vec { if let BundlerProbe::Unsupported { version, source } = probe_bundler(project).await { + let mut message = unsupported_bundler_message(&version, &source); + // An ALREADY-wired project (wired before the floor existed, or on + // another machine) gets the recovery path by name — "Not wiring this + // project" alone would be misleading when the wiring is the problem. + if let Ok(content) = fs::read_to_string(&project.gemfile).await { + if is_plugin_directive_present(&content) { + message.push_str( + ". This project is already wired: run `socket-patch setup --remove` \ + to unwire it so `bundle install` works again", + ); + } + } return vec![GemEditResult { kind: "gemfile", path: project.gemfile.display().to_string(), status: GemSetupStatus::Error, - error: Some(unsupported_bundler_message(&version, &source)), + error: Some(message), }]; } let files = add_plugin_files(&project.root, dry_run).await; @@ -820,6 +832,42 @@ mod tests { ); } + #[tokio::test] + async fn test_add_refusal_on_wired_1x_project_names_remove_recovery() { + // Re-running `setup` on an ALREADY-wired 1.x project must not stop at + // "Not wiring this project" — the wiring IS the problem there, and the + // refusal must hand the user the `setup --remove` recovery path. + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + fs::write(root.join("Gemfile"), gemfile_add(GEMFILE).unwrap()) + .await + .unwrap(); + fs::write(root.join("Gemfile.lock"), LOCK_1X).await.unwrap(); + let project = super::super::discover_bundler_project(root).await.unwrap(); + + let results = add_plugin_directive(&project, false).await; + let msg = results + .iter() + .find_map(|r| r.error.as_deref()) + .unwrap_or_default(); + assert!( + msg.contains("setup --remove"), + "the refusal on a wired project must name the recovery path: {msg:?}" + ); + + // And the UNWIRED refusal must NOT claim the project is wired. + fs::write(root.join("Gemfile"), GEMFILE).await.unwrap(); + let results = add_plugin_directive(&project, false).await; + let msg = results + .iter() + .find_map(|r| r.error.as_deref()) + .unwrap_or_default(); + assert!( + !msg.contains("already wired"), + "an unwired project's refusal must not mention un-wiring: {msg:?}" + ); + } + #[tokio::test] async fn test_remove_still_unwires_bundler_1x_project() { // `setup --remove` is the RECOVERY path for a project wired before diff --git a/crates/socket-patch-core/src/setup/gem/version.rs b/crates/socket-patch-core/src/setup/gem/version.rs index 45b9cfc1..bfd914d6 100644 --- a/crates/socket-patch-core/src/setup/gem/version.rs +++ b/crates/socket-patch-core/src/setup/gem/version.rs @@ -26,11 +26,17 @@ //! refusing there would block every such setup on a guess. use std::path::PathBuf; +use std::time::Duration; use tokio::fs; use super::BundlerProject; +/// Upper bound on the `bundle --version` fallback probe. A wedged bundler +/// (broken RubyGems install, hung shim) must degrade to [`BundlerProbe:: +/// Unknown`] — fail open — rather than hang `setup`/`setup --check` forever. +const BUNDLE_VERSION_TIMEOUT: Duration = Duration::from_secs(10); + /// Minimum bundler `(major, minor)` able to load a `plugin ... path:` /// directive. pub const MIN_BUNDLER: (u64, u64) = (2, 2); @@ -133,14 +139,20 @@ pub async fn probe_bundler(project: &BundlerProject) -> BundlerProbe { } } // No lock (or no BUNDLED WITH): ask the machine's bundler. stdin nulled - // so the child can never block waiting for input. - let output = tokio::process::Command::new("bundle") - .arg("--version") - .current_dir(&project.root) - .stdin(std::process::Stdio::null()) - .output() - .await; - if let Ok(out) = output { + // so the child can never block waiting for input; bounded by + // [`BUNDLE_VERSION_TIMEOUT`] (with `kill_on_drop` so a timed-out child is + // reaped, not leaked) so a wedged bundler degrades to `Unknown`. + let output = tokio::time::timeout( + BUNDLE_VERSION_TIMEOUT, + tokio::process::Command::new("bundle") + .arg("--version") + .current_dir(&project.root) + .stdin(std::process::Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await; + if let Ok(Ok(out)) = output { if out.status.success() { if let Some(version) = parse_bundle_version_output(&String::from_utf8_lossy(&out.stdout))