diff --git a/crates/socket-patch-cli/tests/setup_matrix_gem.rs b/crates/socket-patch-cli/tests/setup_matrix_gem.rs index c8d68164..cb0c202a 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_gem.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_gem.rs @@ -2,20 +2,25 @@ //! — it appends a managed `plugin "socket-patch"` block to the Gemfile and //! generates a committed in-tree Bundler plugin under `.socket/bundler-plugin/` //! whose `plugins.rb` re-runs `socket-patch apply --ecosystems gem` on every -//! `bundle install` (load-time digest gate + `after-install-all` hook). +//! `bundle install` (digest-gated load-time + per-gem `after-install` +//! triggers, forced `after-install-all` re-apply). //! -//! The with-setup Docker cases (`baseline_with_setup`, `alt_content_patchset`) -//! are still a [BASELINE GAP], for two structural reasons (verified -//! 2026-08-13): (a) installing the plugin evaluates `plugins.rb` BEFORE any -//! project gems land, and the load-time `SocketPatch.apply!` treats apply's -//! exit 1 ("No packages found that match available patches") as a genuine -//! failure and raises `Bundler::BundlerError`, so the FIRST `bundle install` -//! after `setup` on a never-installed project always dies — this is the gem -//! twin of the documented apply exit-semantics issue; (b) the matrix fixture's -//! synthetic beforeHashes only pass hash-gated ecosystems via npm's -//! mismatch-warn-and-apply path, which gem apply does not have. The -//! `after-install-all` re-apply flow itself works (asserted in-container with -//! realistic hashes). +//! The two structural reasons the with-setup Docker cases +//! (`baseline_with_setup`, `alt_content_patchset`) used to be a +//! [BASELINE GAP] are both fixed (2026-08-13): (a) the bootstrap deadlock — +//! installing the plugin evaluates `plugins.rb` BEFORE any project gems land, +//! and the old load-time `SocketPatch.apply!` treated apply's exit 1 ("No +//! packages found") as fatal (`Bundler::BundlerError`), killing the FIRST +//! `bundle install` of every fresh checkout — is gone: the generated plugin +//! now warns-and-continues on apply failures (`SOCKET_PATCH_STRICT=1` +//! restores the raise), pinned by [`plugin_runtime`] below; (b) the fixture's +//! synthetic all-zeros beforeHash — which hash-gated gem apply (no npm-style +//! mismatch-warn-and-apply path) always rejected — is replaced by the real +//! git-blob hash probed from the published .gem (`resolve_before_hash` in +//! `run-case.sh`, mirroring docker_e2e_gem). NOTE: in Docker mode the matrix +//! runs the binary BAKED INTO the local image; an image built before this fix +//! generates the old raising plugin and still red-flags these cases — rebuild +//! the image (or run with `SOCKET_PATCH_TEST_HOST=1`) to see them pass. //! //! IMPORTANT — why this file carries a real assertion of its own: //! `smc::run_pm("gem", "bundler")` routes gem through the shared Docker @@ -257,9 +262,25 @@ mod host_guard { rb.contains("\"--ecosystems\", \"gem\", \"--offline\""), "plugins.rb must shell the gem-scoped offline apply:\n{rb}" ); + // Tolerant by default (a raise at plugin registration deadlocks a + // fresh checkout's first `bundle install`), with the strict escape + // hatch still raising Bundler::BundlerError. + assert!( + rb.contains("SOCKET_PATCH_STRICT"), + "plugins.rb must carry the strict-mode escape hatch:\n{rb}" + ); assert!( rb.contains("BundlerError"), - "plugins.rb must fail loud (raise Bundler::BundlerError) on a patch failure:\n{rb}" + "plugins.rb must still raise Bundler::BundlerError in strict mode:\n{rb}" + ); + // The plugin's digest stamp is machine-local, but it lives in the + // otherwise-committed .socket/ — setup must gitignore it, or every + // install litters `git status` and a blanket `git add .socket` + // commits one machine's stamp to every clone. + let gitignore = std::fs::read_to_string(root.join(".socket/.gitignore")).unwrap(); + assert!( + gitignore.lines().any(|l| l == "/gem-plugin-stamp"), + ".socket/.gitignore must carry the stamp entry:\n{gitignore}" ); // ── check (after setup): configured, exit 0 ───────────────────────── @@ -289,6 +310,9 @@ mod host_guard { ); // ── remove: byte-for-byte restore + plugin dir gone ───────────────── + // A stamp left behind by a previous apply: `--remove`'s no-residue + // contract covers it (it sits in the committed .socket/ dir). + std::fs::write(root.join(".socket/gem-plugin-stamp"), "e".repeat(64)).unwrap(); let (code, out, err) = run( root, &["setup", "--remove", "--cwd", root_s, "--yes", "--json"], @@ -309,6 +333,15 @@ mod host_guard { !root.join(PLUGIN_DIR).exists(), "remove must delete the generated plugin dir" ); + assert!( + !root.join(".socket/gem-plugin-stamp").exists(), + "remove must delete the plugin's digest stamp — unwiring must not \ + orphan it in the committed .socket/" + ); + assert!( + !root.join(".socket/.gitignore").exists(), + "remove must delete the .gitignore setup created (it held only our line)" + ); // ── check (after remove): needs_configuration again, exit 1 ───────── let (code, out, _) = run(root, &["setup", "--check", "--cwd", root_s, "--json"]); @@ -387,3 +420,876 @@ mod host_guard { ); } } + +// ───────────────────────────────────────────────────────────────────────── +// Runtime guards for the GENERATED plugin, driven through a REAL `bundle +// install` (host bundler; validated against 4.0.15, and the same flows +// against bundler 2.7 in the gem Docker image during development). Each test +// wires a scratch project with the actual CLI binary (`setup --yes`), points +// SOCKET_PATCH_BIN at a fake apply whose exit code and invocation log we +// control, and asserts on bundler's real exit status + the on-disk state. +// +// Soft-skips (loudly, mirroring the docker_e2e_* convention) when no +// `bundle`/`ruby` toolchain is on PATH — the CI setup-matrix job and any dev +// machine with ruby run them for real. +// ───────────────────────────────────────────────────────────────────────── +#[cfg(unix)] +mod plugin_runtime { + use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; + use std::process::Command; + + /// Manifest fixture: one committed gem patch record (hashes are dummies — + /// the fake apply never checks them; what matters is that the manifest + /// EXISTS so the plugin's applier engages). + const MANIFEST: &str = r#"{ + "patches": { + "pkg:gem/colorize@1.1.0": { + "uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "exportedAt": "2026-01-01T00:00:00Z", + "files": { "package/lib/colorize.rb": { "beforeHash": "0000000000000000000000000000000000000000000000000000000000000000", "afterHash": "1111111111111111111111111111111111111111111111111111111111111111" } }, + "vulnerabilities": {}, + "description": "plugin-runtime fixture", + "license": "MIT", + "tier": "free" + } + } +} +"#; + + /// Hand-pinned stamp locations (independent oracles, not copies of the + /// template constants): the project-scoped stamp the plugin must write, + /// and the legacy fixed-name file it must never write again. + const STAMP_REL: &str = ".socket/gem-plugin-stamp"; + const LEGACY_STAMP_NAME: &str = ".socket-patch-gem-stamp"; + + fn binary() -> PathBuf { + env!("CARGO_BIN_EXE_socket-patch").into() + } + + fn have(cmd: &str) -> bool { + Command::new(cmd) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + + /// Strip every ambient var that could flip a verdict: the CLI's SOCKET_* + /// surface (a dev's SOCKET_PATCH_STRICT or SOCKET_DRY_RUN must not leak + /// into the child), and bundler/rubygems config that could retarget the + /// install (BUNDLE_GEMFILE, GEM_HOME, RUBYOPT). + fn scrub(cmd: &mut Command) { + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy().into_owned(); + let hit = (name.starts_with("SOCKET_") && name != "SOCKET_NO_CONFIG") + || name.starts_with("BUNDLE_") + || name.starts_with("GEM_") + || name == "RUBYOPT"; + if hit { + cmd.env_remove(&name); + } + } + cmd.env("SOCKET_TELEMETRY_DISABLED", "1"); + } + + fn run(mut cmd: Command) -> (i32, String, String) { + let out = cmd.output().expect("spawn child process"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) + } + + /// `bundle install` in `root` with the fake apply bin + extra env. + fn bundle_install(root: &Path, fake: &Path, extra: &[(&str, &str)]) -> (i32, String, String) { + let mut cmd = Command::new("bundle"); + cmd.arg("install").current_dir(root); + scrub(&mut cmd); + cmd.env("BUNDLE_PATH", "vendor/bundle"); + cmd.env("SOCKET_PATCH_BIN", fake); + for (k, v) in extra { + cmd.env(k, v); + } + run(cmd) + } + + /// A fake `socket-patch` that logs each invocation and exits `code`. + /// Returns (bin path, log path). + fn write_fake_apply(dir: &Path, code: i32) -> (PathBuf, PathBuf) { + let log = dir.join("apply.log"); + std::fs::write(&log, "").unwrap(); + let bin = dir.join("fake-socket-patch"); + std::fs::write( + &bin, + format!( + "#!/bin/sh\nprintf 'APPLY-CALLED %s\\n' \"$*\" >> '{}'\nexit {code}\n", + log.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + (bin, log) + } + + fn apply_calls(log: &Path) -> Vec { + std::fs::read_to_string(log) + .unwrap_or_default() + .lines() + .map(str::to_string) + .collect() + } + + /// Scaffold a setup-wired project the way a fresh clone sees it: a + /// Gemfile, a committed manifest, and the plugin generated by the REAL + /// binary. A zero-dependency Gemfile keeps the install offline — bundler + /// still registers the plugin and fires `after-install-all` (verified on + /// bundler 2.7 and 4.0.15), which is all these guards need. + fn scaffold(root: &Path) { + std::fs::write(root.join("Gemfile"), "# no dependencies\n").unwrap(); + std::fs::create_dir_all(root.join(".socket")).unwrap(); + std::fs::write(root.join(".socket/manifest.json"), MANIFEST).unwrap(); + + let mut cmd = Command::new(binary()); + cmd.args(["setup", "--yes", "--json"]).current_dir(root); + scrub(&mut cmd); + let (code, out, err) = run(cmd); + assert_eq!(code, 0, "setup --yes must wire the plugin.\n{out}\n{err}"); + assert!( + root.join(".socket/bundler-plugin/plugins.rb").exists(), + "setup must generate plugins.rb" + ); + } + + /// [P0 bootstrap deadlock] On a fresh clone of a setup-wired project the + /// FIRST `bundle install` evaluates plugins.rb at plugin REGISTRATION, + /// before any project gem lands; apply legitimately finds nothing and + /// exits 1. The old plugin raised Bundler::BundlerError there, so that + /// first install (and every retry) died with "Failed to install plugin" + /// — reproduced at exit 29 under bundler 4.0.15 / exit 1 under 2.7. The + /// generated plugin must instead warn (with the manual remediation) and + /// let the install succeed. + #[test] + fn first_bundle_install_survives_failing_apply() { + if !have("bundle") { + eprintln!("skip plugin_runtime: bundler not on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + let (fake, log) = write_fake_apply(root, 1); + + let (code, out, err) = bundle_install(root, &fake, &[]); + assert_eq!( + code, 0, + "the FIRST bundle install of a setup-wired fresh checkout must \ + succeed even when apply fails (bootstrap deadlock).\n{out}\n{err}" + ); + // Anti-vacuity: the failure was real — the plugin DID shell apply. + let calls = apply_calls(&log); + assert!( + calls + .iter() + .any(|c| c.contains("apply --ecosystems gem --offline --silent")), + "the plugin must have invoked the (failing) gem-scoped apply:\n{calls:?}" + ); + // The warning names what failed and how to remediate. + assert!( + err.contains("socket-patch:"), + "a failing apply must be surfaced on stderr:\n{err}" + ); + assert!( + err.contains("socket-patch apply --ecosystems gem"), + "the warning must name the manual remediation command:\n{err}" + ); + assert!( + err.contains("SOCKET_PATCH_STRICT"), + "the warning must mention the strict escape hatch:\n{err}" + ); + + // A retry is not poisoned either (the old failure mode repeated + // identically forever because plugin registration never completed). + let (code, out, err) = bundle_install(root, &fake, &[]); + assert_eq!( + code, 0, + "retried bundle install must also succeed.\n{out}\n{err}" + ); + } + + /// SOCKET_PATCH_STRICT=1 restores raise-on-failure for builds that must + /// not proceed with unpatched gems: the same failing-apply install must + /// break the build again. + #[test] + fn strict_mode_fails_bundle_install_on_apply_failure() { + if !have("bundle") { + eprintln!("skip plugin_runtime: bundler not on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + let (fake, log) = write_fake_apply(root, 1); + + let (code, out, err) = bundle_install(root, &fake, &[("SOCKET_PATCH_STRICT", "1")]); + assert_ne!( + code, 0, + "strict mode must fail the build on a patch failure.\n{out}\n{err}" + ); + assert!( + !apply_calls(&log).is_empty(), + "the strict failure must come from a real apply invocation" + ); + assert!( + err.contains("socket-patch"), + "the strict failure must carry the socket-patch message:\n{err}" + ); + // The strict raise must tell the truth about the active mode: the + // tolerant trailer ("`bundle install` continues; set + // SOCKET_PATCH_STRICT=1 ...") is false on both counts while the + // install is failing and the var is already set. + assert!( + err.contains("because SOCKET_PATCH_STRICT is set"), + "the strict failure must say WHY the install is failing:\n{err}" + ); + assert!( + !err.contains("`bundle install` continues"), + "the strict failure must not claim the install continues:\n{err}" + ); + } + + /// [P0 regression: committed stale stamp] `.socket/` is a committed + /// directory, so a stamp that reaches version control (a blanket + /// `git add .socket`) arrives on every fresh clone BEFORE the first + /// `bundle install`. If the bootstrap gate keyed on the stamp's + /// existence, plugin REGISTRATION would shell the applier (targets + /// absent -> apply fails) and a strict-mode raise there resurrects the + /// exact deadlock this plugin exists to avoid — exit 29, "Failed to + /// install plugin", every retry identical (reproduced on bundler + /// 4.0.15). The gate must key on the patch targets existing on disk: + /// registration completes, strict enforcement waits for the + /// post-install hooks. + #[test] + fn committed_stale_stamp_does_not_deadlock_strict_fresh_clone() { + if !have("bundle") { + eprintln!("skip plugin_runtime: bundler not on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + // The stale stamp a teammate committed, exactly as a fresh clone sees it. + std::fs::write(root.join(STAMP_REL), "a".repeat(64)).unwrap(); + let (fake, log) = write_fake_apply(root, 1); + + let (code, out, err) = bundle_install(root, &fake, &[("SOCKET_PATCH_STRICT", "1")]); + // Registration must complete — the strict failure may only come from + // the post-install hooks, never from plugin registration. + assert!( + !err.contains("Failed to install plugin"), + "a committed stale stamp must not fail plugin REGISTRATION.\n{out}\n{err}" + ); + assert!( + root.join(".bundle/plugin/index").is_file(), + "registration must be recorded despite the strict failure.\n{out}\n{err}" + ); + assert_ne!( + code, 0, + "strict mode still fails the install — from the hook.\n{out}\n{err}" + ); + assert!( + !apply_calls(&log).is_empty(), + "anti-vacuity: the forced post-install apply ran (and failed)" + ); + + // The deadlock is gone: with apply working, the SAME checkout (stale + // stamp still in place) converges on retry. + let (fake, _log) = write_fake_apply(root, 0); + let (code, out, err) = bundle_install(root, &fake, &[("SOCKET_PATCH_STRICT", "1")]); + assert_eq!( + code, 0, + "retry with a working apply must succeed — registration was never \ + poisoned.\n{out}\n{err}" + ); + } + + /// The bootstrap gate reads the LIVE gem tree, never the stamp. Both + /// directions matter: with no patch target on disk the gated triggers + /// must not shell out no matter what a (stale, possibly committed) stamp + /// says; with the target present they must re-apply even when the stamp + /// is missing — a `bundle pristine` run after deleting the stamp used to + /// leave the patches silently reverted until the next `bundle install` + /// (reproduced on bundler 4.0.15). + #[test] + fn bootstrap_gate_keys_on_target_presence_not_stamp() { + if !have("ruby") { + eprintln!("skip plugin_runtime: ruby not on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + let (fake, log) = write_fake_apply(root, 0); + + // Drive the gated path exactly as the load-time / per-gem hooks do. + let drive = |label: &str| { + let mut cmd = Command::new("ruby"); + cmd.args([ + "-e", + "require \"bundler\"; load ARGV[0]; SocketPatch.apply!(bootstrap_gate: true)", + "--", + ]) + .arg(root.join(".socket/bundler-plugin/plugins.rb")) + .current_dir(root); + scrub(&mut cmd); + cmd.env("BUNDLE_PATH", "vendor/bundle"); + cmd.env("SOCKET_PATCH_BIN", &fake); + let (code, out, err) = run(cmd); + assert_eq!(code, 0, "{label}: gated drive failed.\n{out}\n{err}"); + }; + + // Fresh clone: no target on disk, stale stamp committed. + std::fs::write(root.join(STAMP_REL), "b".repeat(64)).unwrap(); + drive("no target, stale stamp"); + assert_eq!( + apply_calls(&log).len(), + 0, + "no target on disk -> the gated trigger must not shell out, \ + whatever the stamp says" + ); + + // Target installed, stamp deleted: the pristine-heal case. + let mut cmd = Command::new("ruby"); + cmd.args(["-e", "require \"bundler\"; print Bundler.bundle_path"]) + .current_dir(root); + scrub(&mut cmd); + cmd.env("BUNDLE_PATH", "vendor/bundle"); + let (code, bundle_path, err) = run(cmd); + assert_eq!(code, 0, "Bundler.bundle_path probe failed: {err}"); + let target = PathBuf::from(bundle_path.trim()).join("gems/colorize-1.1.0/lib/colorize.rb"); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(&target, "REVERTED BY PRISTINE\n").unwrap(); + std::fs::remove_file(root.join(STAMP_REL)).unwrap(); + + drive("target present, no stamp"); + assert_eq!( + apply_calls(&log).len(), + 1, + "with the target on disk and nothing validly stamped, the gated \ + trigger must re-apply — deleting the stamp defers nothing" + ); + drive("digest-gated no-op"); + assert_eq!( + apply_calls(&log).len(), + 1, + "the re-apply stamped the state; the next gated probe is a no-op" + ); + } + + /// [P2 stamp location] A successful apply stamps the PROJECT + /// (.socket/gem-plugin-stamp), not a fixed-name file under the bundle + /// path (machine-global when no path is configured — shared and clobbered + /// across every socket-patch project on the host). And the digest gate + /// holds: a second, fully-cached install runs exactly one more (forced + /// after-install-all) apply — the gated triggers stay quiet. + #[test] + fn successful_apply_stamps_project_scoped() { + if !have("bundle") { + eprintln!("skip plugin_runtime: bundler not on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + let (fake, log) = write_fake_apply(root, 0); + + let (code, out, err) = bundle_install(root, &fake, &[]); + assert_eq!(code, 0, "install must succeed.\n{out}\n{err}"); + assert!( + !err.contains("socket-patch:"), + "a successful apply must not warn:\n{err}" + ); + + let stamp = root.join(STAMP_REL); + assert!( + stamp.is_file(), + "the digest stamp must land at the project-scoped {STAMP_REL}" + ); + let content = std::fs::read_to_string(&stamp).unwrap(); + let content = content.trim(); + assert!( + content.len() == 64 && content.bytes().all(|b| b.is_ascii_hexdigit()), + "the stamp must hold one sha256 hex digest, got: {content:?}" + ); + // No legacy fixed-name stamp anywhere under the bundle path. + let legacy_hits: Vec<_> = walk(&root.join("vendor")) + .into_iter() + .filter(|p| p.file_name().is_some_and(|n| n == LEGACY_STAMP_NAME)) + .collect(); + assert!( + legacy_hits.is_empty(), + "no legacy bundle-path stamp may be written: {legacy_hits:?}" + ); + + let after_first = apply_calls(&log).len(); + let (code, out, err) = bundle_install(root, &fake, &[]); + assert_eq!(code, 0, "cached install must succeed.\n{out}\n{err}"); + assert_eq!( + apply_calls(&log).len(), + after_first + 1, + "a fully-cached install runs exactly the one forced \ + after-install-all apply; the digest-gated triggers must not \ + shell out again" + ); + } + + /// [P1 digest honesty + migration] Drive the applier directly with plain + /// ruby (no bundler process, no network): the digest stamp must reflect + /// the ACTUAL on-disk gem-file state, so an out-of-band reversion + /// (`bundle pristine`, `gem pristine`, a manual edit) flips the digest + /// and the next trigger re-applies; and the legacy machine-global stamp + /// must be cleaned up, never read. + #[test] + fn digest_tracks_gem_file_content_and_legacy_stamp_is_removed() { + if !have("ruby") { + eprintln!("skip plugin_runtime: ruby not on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + let (fake, log) = write_fake_apply(root, 0); + + // Where bundler would install this project's gems (BUNDLE_PATH set, + // so /vendor/bundle/ruby/). + let mut cmd = Command::new("ruby"); + cmd.args(["-e", "require \"bundler\"; print Bundler.bundle_path"]) + .current_dir(root); + scrub(&mut cmd); + cmd.env("BUNDLE_PATH", "vendor/bundle"); + let (code, bundle_path, err) = run(cmd); + assert_eq!(code, 0, "Bundler.bundle_path probe failed: {err}"); + let bundle_path = PathBuf::from(bundle_path.trim()); + + // The installed file the manifest's patch record targets. + let target = bundle_path.join("gems/colorize-1.1.0/lib/colorize.rb"); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(&target, "UPSTREAM CONTENT\n").unwrap(); + // A stale stamp from the old plugin version in the shared location. + let legacy = bundle_path.join(LEGACY_STAMP_NAME); + std::fs::write(&legacy, "stale digest from another project\n").unwrap(); + + let drive = |label: &str| { + let mut cmd = Command::new("ruby"); + cmd.args([ + "-e", + "require \"bundler\"; load ARGV[0]; SocketPatch.apply!", + "--", + ]) + .arg(root.join(".socket/bundler-plugin/plugins.rb")) + .current_dir(root); + scrub(&mut cmd); + cmd.env("BUNDLE_PATH", "vendor/bundle"); + cmd.env("SOCKET_PATCH_BIN", &fake); + let (code, out, err) = run(cmd); + assert_eq!( + code, 0, + "{label}: driving the applier failed.\n{out}\n{err}" + ); + }; + + drive("initial apply"); + assert_eq!( + apply_calls(&log).len(), + 1, + "first drive must shell apply (nothing stamped yet)" + ); + assert!(root.join(STAMP_REL).is_file(), "stamp written"); + assert!( + !legacy.exists(), + "the legacy bundle-path stamp must be deleted on the first run" + ); + + drive("stamped no-op"); + assert_eq!( + apply_calls(&log).len(), + 1, + "unchanged state must be digest-gated to a no-op" + ); + + // Out-of-band reversion: the committed inputs (manifest, blobs, lock) + // are untouched — only the installed gem file changed back. + std::fs::write(&target, "REVERTED BY PRISTINE\n").unwrap(); + drive("after reversion"); + assert_eq!( + apply_calls(&log).len(), + 2, + "reverting the installed gem file must flip the digest and \ + re-run apply — a manifest-only digest misses this" + ); + } + + /// [P2 Windows platform-gem glob] `Bundler.bundle_path` carries + /// backslash separators through verbatim (Windows spelling), and + /// `Dir.glob` treats `\` as an escape on EVERY platform — so the + /// platform-install wildcard (`/--*/`) built + /// from that base escape-eats the separator, matches nothing, and + /// platform installs (colorize-1.1.0-x64-mingw-ucrt) silently drop out + /// of the digest: a `bundle pristine` reversion of them leaves the stamp + /// matching and the re-apply skipped. Simulated on this host by feeding + /// bundler a backslash-bearing BUNDLE_PATH while the real tree lives at + /// the forward-slash spelling — exactly the two-spellings-one-directory + /// situation Windows creates (glob escape semantics are identical + /// everywhere). The plugin must glob a slash-normalized base; forward + /// slashes are valid separators on Windows. + #[test] + fn backslash_bundle_path_still_digests_platform_gem_files() { + if !have("ruby") { + eprintln!("skip plugin_runtime: ruby not on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + scaffold(root); + let (fake, log) = write_fake_apply(root, 0); + + // Where bundler puts this project's gems under the backslash spelling. + let mut cmd = Command::new("ruby"); + cmd.args(["-e", "require \"bundler\"; print Bundler.bundle_path"]) + .current_dir(root); + scrub(&mut cmd); + cmd.env("BUNDLE_PATH", "vendor\\bundle"); + let (code, bundle_path, err) = run(cmd); + assert_eq!(code, 0, "Bundler.bundle_path probe failed: {err}"); + let bundle_path = bundle_path.trim().to_string(); + assert!( + bundle_path.contains('\\'), + "precondition: bundler must carry the backslash spelling through \ + verbatim (the Windows behavior this test simulates), got: \ + {bundle_path:?}" + ); + + // On Windows both spellings denote the SAME directory; materialize + // the real tree at the slash spelling — the one the normalized glob + // must reach from the backslash-bearing base. Only a PLATFORM install + // exists: the plain `colorize-1.1.0` direct join never globs and is + // not at issue. + let target = PathBuf::from(bundle_path.replace('\\', "/")) + .join("gems/colorize-1.1.0-x64-mingw-ucrt/lib/colorize.rb"); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(&target, "PATCHED PLATFORM CONTENT\n").unwrap(); + + let drive = |label: &str| { + let mut cmd = Command::new("ruby"); + cmd.args([ + "-e", + "require \"bundler\"; load ARGV[0]; SocketPatch.apply!", + "--", + ]) + .arg(root.join(".socket/bundler-plugin/plugins.rb")) + .current_dir(root); + scrub(&mut cmd); + cmd.env("BUNDLE_PATH", "vendor\\bundle"); + cmd.env("SOCKET_PATCH_BIN", &fake); + let (code, out, err) = run(cmd); + assert_eq!( + code, 0, + "{label}: driving the applier failed.\n{out}\n{err}" + ); + }; + + drive("initial apply"); + assert_eq!( + apply_calls(&log).len(), + 1, + "first drive must shell apply (nothing stamped yet)" + ); + assert!(root.join(STAMP_REL).is_file(), "stamp written"); + drive("stamped no-op"); + assert_eq!( + apply_calls(&log).len(), + 1, + "unchanged state must be digest-gated to a no-op" + ); + + // The pristine reversion the digest exists to catch — of the + // PLATFORM install this time. + std::fs::write(&target, "REVERTED BY PRISTINE\n").unwrap(); + drive("after platform-install reversion"); + assert_eq!( + apply_calls(&log).len(), + 2, + "reverting the platform gem install must flip the digest and \ + re-run apply — an escape-eaten glob omits platform installs \ + from the digest and skips this re-apply" + ); + + // And directly: the platform install is enumerated as a patch target. + let mut cmd = Command::new("ruby"); + cmd.args([ + "-e", + "require \"bundler\"; load ARGV[0]; puts SocketPatch.patch_target_files", + "--", + ]) + .arg(root.join(".socket/bundler-plugin/plugins.rb")) + .current_dir(root); + scrub(&mut cmd); + cmd.env("BUNDLE_PATH", "vendor\\bundle"); + cmd.env("SOCKET_PATCH_BIN", &fake); + let (code, out, err) = run(cmd); + assert_eq!(code, 0, "patch_target_files probe failed.\n{out}\n{err}"); + assert!( + out.contains("colorize-1.1.0-x64-mingw-ucrt"), + "patch_target_files must enumerate the platform install under a \ + backslash-bearing bundle path:\n{out}" + ); + } + + fn walk(dir: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return out; + }; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + out.extend(walk(&p)); + } else { + out.push(p); + } + } + out + } +} + +// ───────────────────────────────────────────────────────────────────────── +// Guards for the RubyGems CLI launcher (gem/socket-patch), driven with the +// host ruby. Ruby-gated with a loud skip, like `plugin_runtime` above. +// ───────────────────────────────────────────────────────────────────────── +#[cfg(unix)] +mod launcher_guard { + use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; + use std::process::Command; + + fn have_ruby() -> bool { + Command::new("ruby") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + + /// The launcher under test, resolved from the workspace checkout. + fn launcher_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../gem/socket-patch/lib/socket_patch/launcher.rb") + .canonicalize() + .expect("launcher.rb must exist in the workspace") + } + + /// Strip the ambient vars that could flip a verdict, mirroring + /// `plugin_runtime::scrub`: the CLI's SOCKET_* surface, and the + /// bundler/rubygems config a `bundle exec cargo test` run injects + /// (RUBYOPT=-rbundler/setup, BUNDLE_*, GEM_*) — which would activate a + /// foreign bundle inside the child ruby under test. + fn scrub(cmd: &mut Command) { + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy().into_owned(); + let hit = (name.starts_with("SOCKET_") && name != "SOCKET_NO_CONFIG") + || name.starts_with("BUNDLE_") + || name.starts_with("GEM_") + || name == "RUBYOPT"; + if hit { + cmd.env_remove(&name); + } + } + } + + /// Run `script` (which `load`s the launcher via the SP_LAUNCHER env) with + /// a scratch HOME/cache so no real launcher cache is consulted. + fn run_ruby(script: &Path, cache: &Path, envs: &[(&str, &str)]) -> (i32, String, String) { + let mut cmd = Command::new("ruby"); + cmd.arg(script); + scrub(&mut cmd); + cmd.env("SP_LAUNCHER", launcher_path()); + cmd.env("XDG_CACHE_HOME", cache); + for (k, v) in envs { + cmd.env(k, v); + } + let out = cmd.output().expect("spawn ruby"); + ( + out.status.code().unwrap_or(-1), + String::from_utf8_lossy(&out.stdout).to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) + } + + fn write_executable(path: &Path, body: &str) { + std::fs::write(path, body).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + /// [P2a] The Windows arm must propagate the child's REAL exit code — the + /// old `system(...) ? $?.exitstatus : 1` collapsed every non-zero exit to + /// 1, erasing meaningful codes like `setup --check`'s needs-configuration + /// signal. Forced onto the Windows branch by stubbing `Gem.win_platform?`. + #[test] + fn windows_branch_propagates_child_exit_code() { + if !have_ruby() { + eprintln!("skip launcher_guard: ruby not on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let child = tmp.path().join("exit7"); + write_executable(&child, "#!/bin/sh\nexit 7\n"); + let script = tmp.path().join("drive.rb"); + std::fs::write( + &script, + "require \"rubygems\"\n\ + def Gem.win_platform?; true; end\n\ + load ENV.fetch(\"SP_LAUNCHER\")\n\ + SocketPatch::Launcher.run([\"anything\"])\n", + ) + .unwrap(); + + let (code, out, err) = run_ruby( + &script, + &tmp.path().join("cache"), + &[("SOCKET_PATCH_BIN", child.to_str().unwrap())], + ); + assert_eq!( + code, 7, + "the child's exit 7 must survive the spawn+wait arm, not \ + collapse to 1.\nstdout:\n{out}\nstderr:\n{err}" + ); + } + + /// [nit] First-run failures outside LauncherError (DNS outages, TLS + /// errors, ...) must exit with a clean one-line message, not a raw ruby + /// backtrace. + #[test] + fn unexpected_download_errors_exit_cleanly() { + if !have_ruby() { + eprintln!("skip launcher_guard: ruby not on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let script = tmp.path().join("drive.rb"); + std::fs::write( + &script, + "require \"net/http\"\n\ + def (Net::HTTP).start(*args, &block)\n\ + raise SocketError, \"simulated dns failure\"\n\ + end\n\ + load ENV.fetch(\"SP_LAUNCHER\")\n\ + SocketPatch::Launcher.run([\"--version\"])\n", + ) + .unwrap(); + + let (code, out, err) = run_ruby(&script, &tmp.path().join("cache"), &[]); + assert_eq!(code, 1, "a download failure exits 1.\n{out}\n{err}"); + assert!( + err.contains("socket-patch:") && err.contains("SocketError"), + "the failure must be reported as a clean launcher message:\n{err}" + ); + assert!( + !err.contains("launcher.rb:"), + "no raw backtrace frames may escape to the user:\n{err}" + ); + } + + /// [nit] PowerShell quoting: a path containing a single quote must be + /// escaped by doubling it, or the Expand-Archive fallback command breaks. + #[test] + fn powershell_quote_doubles_single_quotes() { + if !have_ruby() { + eprintln!("skip launcher_guard: ruby not on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let script = tmp.path().join("drive.rb"); + std::fs::write( + &script, + "load ENV.fetch(\"SP_LAUNCHER\")\n\ + print SocketPatch::Launcher.powershell_quote(\"C:/it's a dir/x.zip\")\n", + ) + .unwrap(); + + let (code, out, err) = run_ruby(&script, &tmp.path().join("cache"), &[]); + assert_eq!(code, 0, "quoting helper must exist and run.\n{err}"); + assert_eq!( + out, "'C:/it''s a dir/x.zip'", + "single quotes must be doubled inside the single-quoted literal" + ); + // And the Expand-Archive fallback actually routes through it. + let launcher = std::fs::read_to_string(launcher_path()).unwrap(); + assert!( + launcher.contains("-LiteralPath #{powershell_quote(archive_path)}") + && launcher.contains("-DestinationPath #{powershell_quote(dir)}"), + "extract's PowerShell fallback must quote both paths via the helper" + ); + } + + /// [P2b] The binary-cache install must be atomic: staged in the + /// destination dir and renamed into place, leaving no temp litter — a + /// concurrent first run can then never exec a torn or not-yet-chmodded + /// binary. (The rename mechanism itself is pinned by inspection since a + /// mid-write race cannot be scheduled deterministically from a test.) + #[test] + fn install_executable_is_atomic_into_place() { + if !have_ruby() { + eprintln!("skip launcher_guard: ruby not on PATH"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let src = tmp.path().join("extracted-binary"); + std::fs::write(&src, "BINARY CONTENT\n").unwrap(); + let dest = tmp.path().join("cache/1.0.0/target/socket-patch"); + let script = tmp.path().join("drive.rb"); + std::fs::write( + &script, + "load ENV.fetch(\"SP_LAUNCHER\")\n\ + SocketPatch::Launcher.install_executable(ARGV[0], ARGV[1])\n", + ) + .unwrap(); + + let mut cmd = Command::new("ruby"); + cmd.arg(&script).arg(&src).arg(&dest); + scrub(&mut cmd); + cmd.env("SP_LAUNCHER", launcher_path()); + let out = cmd.output().expect("spawn ruby"); + assert!( + out.status.success(), + "install_executable must exist and succeed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + std::fs::read_to_string(&dest).unwrap(), + "BINARY CONTENT\n", + "the cached binary must be byte-identical to the extracted one" + ); + let mode = std::fs::metadata(&dest).unwrap().permissions().mode(); + assert_ne!(mode & 0o111, 0, "the cached binary must be executable"); + let litter: Vec<_> = std::fs::read_dir(dest.parent().unwrap()) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n != "socket-patch") + .collect(); + assert!( + litter.is_empty(), + "no staging temp files may be left behind: {litter:?}" + ); + let launcher = std::fs::read_to_string(launcher_path()).unwrap(); + assert!( + launcher.contains("File.rename(tmp, dest)"), + "the cache publish must go through a same-dir rename" + ); + } +} diff --git a/crates/socket-patch-core/src/setup/gem/mod.rs b/crates/socket-patch-core/src/setup/gem/mod.rs index c4cbf8e1..871aa399 100644 --- a/crates/socket-patch-core/src/setup/gem/mod.rs +++ b/crates/socket-patch-core/src/setup/gem/mod.rs @@ -1,22 +1,32 @@ //! Gem (Bundler) `setup` support: wire a Ruby project for automatic patching. //! -//! Bundler has no after-each-install hook that survives a cached/no-op -//! `bundle install`, but it loads any declared **plugin** during the Gemfile -//! pass on every `bundle` invocation. So setup delivers the gate as a -//! generated, git-committed Bundler plugin plus a `plugin` directive in the -//! Gemfile: +//! Bundler loads a declared **plugin** whenever one of its subscribed hook +//! events fires — on every `bundle install`, fresh AND fully cached (verified +//! against bundler 2.7 and 4.0). So setup delivers the gate as a generated, +//! git-committed Bundler plugin plus a `plugin` directive in the Gemfile: //! //! * `.socket/bundler-plugin/{plugins.rb, socket-patch.gemspec}` — a generated //! plugin whose `plugins.rb` re-runs `socket-patch apply --ecosystems gem` -//! on every `bundle install` (load-time digest gate + `after-install-all` -//! hook), failing the build loudly on a patch failure; +//! on every `bundle install` (digest-gated load-time + per-gem +//! `after-install` triggers, forced `after-install-all` re-apply). A patch +//! failure warns with a remediation and lets the install continue — +//! bundler evaluates `plugins.rb` at plugin REGISTRATION, before any +//! project gem is installed, so raising there would deadlock a fresh +//! clone on its own first `bundle install` (plugin registration fails and +//! every retry fails identically). `SOCKET_PATCH_STRICT=1` restores +//! raise-on-failure (`Bundler::BundlerError`); //! * a managed block appended to the `Gemfile` that references the plugin via //! `plugin "socket-patch", path: File.expand_path(".socket/bundler-plugin", //! __dir__)`. The source must be `path:` — Bundler fetches `git:` plugin //! sources with `git clone`, and the generated dir is a plain directory //! (committing it to the parent repo does not give it a `.git`), so `git:` //! fails every `bundle install`. The directory still must be committed so -//! clones and CI have the plugin on disk. +//! clones and CI have the plugin on disk; +//! * a `.socket/.gitignore` entry for the plugin's digest stamp +//! (`gem-plugin-stamp`) — everything else under `.socket/` is meant to be +//! committed, and an untracked stamp in every wired repo's `git status` +//! invites a `git add .socket` that ships one machine's digest to every +//! clone. //! //! The actual gem patching is done by `apply` (unchanged); this module only //! manages the setup wiring. Phase 2 (follow-up) replaces the in-tree plugin @@ -38,6 +48,15 @@ const PLUGIN_DIR: &str = ".socket/bundler-plugin"; /// First line of every generated plugin file — the ownership signal for removal /// (we never delete a file that lacks it). const GENERATED_MARKER: &str = "# Code generated by `socket-patch setup`. DO NOT EDIT."; +/// The generated plugin's digest stamp, relative to the project root +/// (machine-local state; the plugin owns its content, setup owns its lifecycle). +const STAMP_REL: &str = ".socket/gem-plugin-stamp"; +/// The `.socket/.gitignore` line that keeps the stamp out of version control. +/// Everything else under `.socket/` is meant to be committed, so without this +/// line every install drops an untracked stamp into `git status` — and a stamp +/// committed by a blanket `git add .socket` ships one machine's digest to +/// every clone. +const STAMP_IGNORE_LINE: &str = "/gem-plugin-stamp"; /// The generated `plugins.rb` body (the two-trigger idempotent applier). const PLUGINS_RB: &str = include_str!("templates/plugins.rb.tmpl"); @@ -126,6 +145,63 @@ fn gemspec_path(root: &Path) -> PathBuf { plugin_dir(root).join("socket-patch.gemspec") } +fn stamp_path(root: &Path) -> PathBuf { + root.join(STAMP_REL) +} + +fn stamp_gitignore_path(root: &Path) -> PathBuf { + root.join(".socket").join(".gitignore") +} + +/// Whether `.socket/.gitignore` is missing the stamp entry (so `setup` still +/// has a write to make). Shared by [`add_plugin_files`] and +/// [`plugin_files_present`] to keep `setup` and `--check` in agreement. +async fn stamp_ignore_missing(root: &Path) -> bool { + match fs::read_to_string(stamp_gitignore_path(root)).await { + Ok(c) => !c.lines().any(|l| l.trim() == STAMP_IGNORE_LINE), + Err(_) => true, + } +} + +/// Append the stamp entry to `.socket/.gitignore`, preserving any existing +/// (user or other-tool) lines. Creates the file when absent. +async fn add_stamp_gitignore(root: &Path) -> Result<(), String> { + let path = stamp_gitignore_path(root); + let existing = fs::read_to_string(&path).await.unwrap_or_default(); + let mut body = existing; + if !body.is_empty() && !body.ends_with('\n') { + body.push('\n'); + } + body.push_str(STAMP_IGNORE_LINE); + body.push('\n'); + write_file(&path, &body).await +} + +/// Best-effort cleanup of the stamp artifacts on `--remove`: delete the stamp +/// itself and strip our line from `.socket/.gitignore` (deleting the file when +/// nothing else is left, sparing any other lines). Best-effort by design — a +/// leftover stamp is inert machine-local state, not worth failing an +/// otherwise-successful unwire over. +async fn remove_stamp_artifacts(root: &Path) { + let _ = fs::remove_file(stamp_path(root)).await; + let path = stamp_gitignore_path(root); + let Ok(content) = fs::read_to_string(&path).await else { + return; + }; + let kept: Vec<&str> = content + .lines() + .filter(|l| l.trim() != STAMP_IGNORE_LINE) + .collect(); + if kept.len() == content.lines().count() { + return; + } + if kept.iter().all(|l| l.trim().is_empty()) { + let _ = fs::remove_file(&path).await; + } else { + let _ = fs::write(&path, format!("{}\n", kept.join("\n"))).await; + } +} + /// Whether the generated plugin files are present *and* match the templates the /// current CLI generates (the `setup --check` "configured" signal, paired with /// the Gemfile directive check). @@ -142,6 +218,7 @@ fn gemspec_path(root: &Path) -> PathBuf { pub async fn plugin_files_present(root: &Path) -> bool { !needs_write(&plugins_rb_path(root), PLUGINS_RB).await && !needs_write(&gemspec_path(root), GEMSPEC).await + && !stamp_ignore_missing(root).await } /// True if the file is absent or its content differs from `desired`. @@ -163,15 +240,17 @@ async fn write_file(path: &Path, body: &str) -> Result<(), String> { .map_err(|e| format!("write {}: {e}", path.display())) } -/// Generate `.socket/bundler-plugin/{plugins.rb, socket-patch.gemspec}`. -/// Idempotent: `AlreadyConfigured` when both already match the templates byte -/// for byte. `kind = "gem_plugin"`. +/// Generate `.socket/bundler-plugin/{plugins.rb, socket-patch.gemspec}` and +/// make sure `.socket/.gitignore` keeps the plugin's digest stamp untracked. +/// Idempotent: `AlreadyConfigured` when everything already matches. `kind = +/// "gem_plugin"`. async fn add_plugin_files(root: &Path, dry_run: bool) -> GemEditResult { let dir = plugin_dir(root); let result = async { let rb_changed = needs_write(&plugins_rb_path(root), PLUGINS_RB).await; let spec_changed = needs_write(&gemspec_path(root), GEMSPEC).await; - if !rb_changed && !spec_changed { + let ignore_missing = stamp_ignore_missing(root).await; + if !rb_changed && !spec_changed && !ignore_missing { return Ok(false); } if !dry_run { @@ -181,6 +260,9 @@ async fn add_plugin_files(root: &Path, dry_run: bool) -> GemEditResult { if spec_changed { write_file(&gemspec_path(root), GEMSPEC).await?; } + if ignore_missing { + add_stamp_gitignore(root).await?; + } } Ok(true) } @@ -209,8 +291,11 @@ async fn remove_generated(path: &Path) -> Result<(), String> { /// Remove the generated plugin files — each only when it carries our /// [`GENERATED_MARKER`], so a user-authored file at either path is never -/// deleted (and an orphaned generated file is still cleaned up). Idempotent: -/// `AlreadyConfigured` when nothing of ours is there. +/// deleted (and an orphaned generated file is still cleaned up) — plus the +/// plugin's digest stamp and its `.socket/.gitignore` entry (the plugin that +/// maintained them is being unwired; leaving the stamp behind would orphan it +/// in the otherwise-committed `.socket/`). Idempotent: `AlreadyConfigured` +/// when nothing of ours is there. async fn remove_plugin_files(root: &Path, dry_run: bool) -> GemEditResult { let dir = plugin_dir(root); let result = async { @@ -226,6 +311,7 @@ async fn remove_plugin_files(root: &Path, dry_run: bool) -> GemEditResult { if spec_ours { remove_generated(&gemspec_path(root)).await?; } + remove_stamp_artifacts(root).await; // Prune the now-empty plugin dir (leave .socket/ — apply uses it). let _ = fs::remove_dir(&dir).await; } @@ -405,21 +491,27 @@ mod tests { #[test] fn test_templates_are_well_formed() { - // The plugin must carry the ownership marker and both triggers. + // The plugin must carry the ownership marker and all three triggers. assert!(PLUGINS_RB.starts_with(GENERATED_MARKER)); assert!(PLUGINS_RB.contains("def apply!")); - // Load-time trigger + after-install-all hook. + // Load-time trigger + the per-gem after-install hook (the only event + // bundler fires during `bundle pristine`) + after-install-all hook. assert!(PLUGINS_RB.contains("SocketPatch.apply!")); + assert!(PLUGINS_RB.contains("Bundler::Plugin.add_hook(\"after-install\")")); assert!(PLUGINS_RB.contains("Bundler::Plugin.add_hook(\"after-install-all\")")); - // The applier shells the gem-scoped offline apply and fails loud. + // The applier shells the gem-scoped offline apply. assert!(PLUGINS_RB.contains("\"apply\"")); assert!(PLUGINS_RB.contains("\"--ecosystems\", \"gem\", \"--offline\"")); - assert!(PLUGINS_RB.contains("BundlerError")); - // Stamp travels with the gems (under Bundler.bundle_path). - assert!(PLUGINS_RB.contains("Bundler.bundle_path")); - // Digest folds in Gemfile.lock + the manifest. + // Digest folds in Gemfile.lock + the manifest + the on-disk state of + // the patch target files (so an out-of-band reversion is detected). assert!(PLUGINS_RB.contains("Gemfile.lock")); assert!(PLUGINS_RB.contains("manifest.json")); + assert!(PLUGINS_RB.contains("def patch_target_files")); + // The platform-gem wildcard must glob a forward-slash base: Dir.glob + // treats `\` as an escape on every platform, so a Windows bundle path + // (backslash separators) would otherwise never match platform installs + // and they would silently drop out of the digest. + assert!(PLUGINS_RB.contains(r#"glob_gems_dir = gems_dir.tr("\\", "/")"#)); // The gemspec names the plugin the Gemfile directive references. assert!(GEMSPEC.starts_with(GENERATED_MARKER)); assert!(GEMSPEC.contains("\"socket-patch\"")); @@ -430,6 +522,86 @@ mod tests { assert!(GEMSPEC.contains("s.require_paths = [\".\"]")); } + #[test] + fn test_plugin_template_failure_policy_and_stamp_location() { + // Failure policy: tolerant by default — a patch failure WARNS (with + // the manual-apply remediation) and lets `bundle install` continue. + // Bundler evaluates plugins.rb at plugin REGISTRATION, before any + // project gem is installed; a raise there deadlocks a fresh clone on + // its own first `bundle install` (plugin registration fails, every + // retry fails identically — reproduced against bundler 2.7 and 4.0). + assert!( + PLUGINS_RB.contains("def report_failure"), + "the applier must route failures through the tolerant reporter" + ); + assert!( + !PLUGINS_RB.contains("def fail!"), + "the unconditional raise helper must be gone — it is what \ + deadlocked bootstrap installs" + ); + assert!( + PLUGINS_RB.contains("warn(message)"), + "tolerant mode must surface the failure as a stderr warning" + ); + assert!( + PLUGINS_RB.contains("socket-patch apply --ecosystems gem"), + "the warning must name the manual remediation command" + ); + // Strict escape hatch: SOCKET_PATCH_STRICT=1 restores raise-on-failure. + assert!(PLUGINS_RB.contains("SOCKET_PATCH_STRICT")); + assert!( + PLUGINS_RB.contains("BundlerError"), + "strict mode must still raise Bundler::BundlerError" + ); + // The strict raise must carry a strict trailer, not the tolerant + // "`bundle install` continues" text (which is false mid-raise). + assert!(PLUGINS_RB.contains("def failure_trailer")); + // Stamp location: project-scoped under .socket/, NOT a fixed-name file + // in Bundler.bundle_path (machine-global with no bundle path + // configured, shared and clobbered across every project on the host). + assert!(PLUGINS_RB.contains("STAMP_NAME = \"gem-plugin-stamp\"")); + assert!(PLUGINS_RB.contains("File.join(socket_dir, STAMP_NAME)")); + // The legacy global stamp is cleaned up, never read. + assert!(PLUGINS_RB.contains("LEGACY_STAMP_NAME = \".socket-patch-gem-stamp\"")); + assert!(PLUGINS_RB.contains("def remove_legacy_stamp")); + // The stamp must be excluded from its own digest inputs, or every + // write would invalidate the digest it records. + assert!(PLUGINS_RB.contains("p != stamp_path")); + // The bootstrap gate keys on the patch targets existing on disk, NEVER + // on the stamp: `.socket/` is a committed directory, so a stale stamp + // that reaches version control would otherwise pass the gate at plugin + // REGISTRATION on a fresh clone and resurrect the strict-mode + // bootstrap deadlock this plugin exists to avoid. + assert!(PLUGINS_RB.contains("bootstrap_gate && patch_target_files.none?")); + assert!( + !PLUGINS_RB.contains("require_stamp"), + "the stamp-existence gate must be gone — a committed stamp made it \ + a remote-controlled registration deadlock" + ); + + // The published-gem twin must carry the same applier contract. + let published = include_str!("../../../../../gem/socket-patch-bundler/plugins.rb"); + for needle in [ + "def report_failure", + "def failure_trailer", + "SOCKET_PATCH_STRICT", + "STAMP_NAME = \"gem-plugin-stamp\"", + "def remove_legacy_stamp", + "def patch_target_files", + r#"glob_gems_dir = gems_dir.tr("\\", "/")"#, + "bootstrap_gate && patch_target_files.none?", + "Bundler::Plugin.add_hook(\"after-install\")", + "Bundler::Plugin.add_hook(\"after-install-all\")", + ] { + assert!( + published.contains(needle), + "published plugins.rb drifted from the template: missing {needle:?}" + ); + } + assert!(!published.contains("def fail!")); + assert!(!published.contains("require_stamp")); + } + #[tokio::test] async fn test_add_then_remove_plugin_files_roundtrip() { let dir = tempfile::tempdir().unwrap(); @@ -441,16 +613,32 @@ mod tests { fs::read_to_string(plugins_rb_path(root)).await.unwrap(), PLUGINS_RB ); + // The stamp is gitignored: everything else under .socket/ is committed, + // so without this line every install litters `git status` and a blanket + // `git add .socket` commits one machine's stamp to every clone. + assert_eq!( + fs::read_to_string(stamp_gitignore_path(root)) + .await + .unwrap(), + "/gem-plugin-stamp\n" + ); // Idempotent. assert_eq!( add_plugin_files(root, false).await.status, GemSetupStatus::AlreadyConfigured ); - // Remove. + // Remove — including the stamp a previous apply left behind, which + // would otherwise be orphaned in the committed .socket/ dir. + fs::write(stamp_path(root), "f".repeat(64)).await.unwrap(); let rr = remove_plugin_files(root, false).await; assert_eq!(rr.status, GemSetupStatus::Updated); assert!(!plugin_files_present(root).await); assert!(!plugin_dir(root).exists(), "empty plugin dir pruned"); + assert!(!stamp_path(root).exists(), "stamp removed with the plugin"); + assert!( + !stamp_gitignore_path(root).exists(), + "the .gitignore we created (nothing but our line) is removed too" + ); // Remove again → already gone. assert_eq!( remove_plugin_files(root, false).await.status, @@ -686,8 +874,9 @@ mod tests { } #[tokio::test] - async fn test_plugin_files_present_requires_both() { - // The "configured" signal must demand BOTH files, not either one. + async fn test_plugin_files_present_requires_all_three() { + // The "configured" signal must demand BOTH generated files AND the + // stamp's .gitignore entry, not any subset. let dir = tempfile::tempdir().unwrap(); let root = dir.path(); write(&plugins_rb_path(root), PLUGINS_RB).await; @@ -696,6 +885,62 @@ mod tests { "plugins.rb alone is not 'configured' — the gemspec is required too" ); write(&gemspec_path(root), GEMSPEC).await; + assert!( + !plugin_files_present(root).await, + "check and setup must agree: setup would still write the stamp's \ + .gitignore entry, so this is not 'configured' yet" + ); + assert_eq!( + add_plugin_files(root, true).await.status, + GemSetupStatus::Updated, + "setup agrees it still has the .gitignore write to make" + ); + write(&stamp_gitignore_path(root), "/gem-plugin-stamp\n").await; + assert!(plugin_files_present(root).await); + } + + #[tokio::test] + async fn test_stamp_gitignore_add_and_remove_spare_user_lines() { + // A user's own .socket/.gitignore entries must survive both the add + // (line appended, not the file clobbered) and the remove (only our + // line stripped, file kept). + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(&stamp_gitignore_path(root), "my-scratch-dir/\n").await; + + add_plugin_files(root, false).await; + assert_eq!( + fs::read_to_string(stamp_gitignore_path(root)) + .await + .unwrap(), + "my-scratch-dir/\n/gem-plugin-stamp\n", + "our line is appended after the user's" + ); + + remove_plugin_files(root, false).await; + assert_eq!( + fs::read_to_string(stamp_gitignore_path(root)) + .await + .unwrap(), + "my-scratch-dir/\n", + "only our line is stripped; the user's file survives" + ); + } + + #[tokio::test] + async fn test_add_appends_gitignore_line_to_unterminated_file() { + // A .gitignore whose last line has no trailing newline must not have + // our entry glued onto it ("vendor//gem-plugin-stamp" ignores nothing). + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + write(&stamp_gitignore_path(root), "vendor/").await; + add_plugin_files(root, false).await; + assert_eq!( + fs::read_to_string(stamp_gitignore_path(root)) + .await + .unwrap(), + "vendor/\n/gem-plugin-stamp\n" + ); assert!(plugin_files_present(root).await); } } diff --git a/crates/socket-patch-core/src/setup/gem/templates/plugins.rb.tmpl b/crates/socket-patch-core/src/setup/gem/templates/plugins.rb.tmpl index 9103b26f..4c5fc5b9 100644 --- a/crates/socket-patch-core/src/setup/gem/templates/plugins.rb.tmpl +++ b/crates/socket-patch-core/src/setup/gem/templates/plugins.rb.tmpl @@ -1,39 +1,70 @@ # Code generated by `socket-patch setup`. DO NOT EDIT. # # socket-patch Bundler plugin. Keeps the gem patches recorded in -# .socket/manifest.json applied on every `bundle install` — including a -# cached/no-op install that restores a previously-installed gem set — by -# re-running the socket-patch CLI. Without it, `bundle install` reinstalls a gem +# .socket/manifest.json applied by re-running the socket-patch CLI whenever +# Bundler touches the gem set. Without it, `bundle install` reinstalls a gem # from its cached .gem and silently reverts any applied patch. # -# Two complementary triggers feed one idempotent applier: -# * load-time — this file is evaluated during Bundler's Gemfile pass on EVERY -# `bundle` invocation, even when no gem needs installing, so it covers the -# cached/no-op install the after-install-all hook would miss; -# * the `after-install-all` hook — fires after the installer finishes, so it -# covers the fresh install where gems exist only afterwards (at load time -# there was nothing on disk to patch yet). +# When each trigger actually runs (verified against bundler 2.7 and 4.0; hook +# subscriptions are recorded in .bundle/plugin/index at plugin REGISTRATION, +# and bundler evaluates this file whenever a subscribed event first fires in a +# bundle process): # -# A digest of (manifest + every committed patch file under .socket/ + -# Gemfile.lock) gates the load-time work: identical to the last applied state -> -# fast exit; otherwise shell out and re-stamp. Folding Gemfile.lock into the -# digest forces a reapply when a version bump reinstalls a gem and wipes its -# patch even though the manifest is byte-identical. The stamp lives under -# Bundler.bundle_path so it travels WITH the gems: a cached gem dir carries the -# stamp alongside the patched gems (stays in sync); a wiped vendor/bundle drops -# the stamp too, so patches reapply. +# * plugin registration — the FIRST `bundle install` after `setup` (or on a +# fresh clone) evaluates this file BEFORE any project gem is installed, so +# the load-time trigger is bootstrap-gated (no patch target exists yet) +# and quietly no-ops there; the install hooks below re-apply once the +# gems land. +# * every `bundle install` — fresh AND fully cached — fires the per-gem +# `after-install` events and then `after-install-all`; the forced +# `after-install-all` re-apply is the actual patch point. +# * `bundle pristine` fires ONLY the per-gem `after-install` events, so that +# digest-gated hook is what catches pristine's patch reversion in the same +# run. (A checkout registered by an older plugin version keeps its old +# subscription set until it re-registers — fresh clones and CI always +# re-register, a dev checkout can `rm -rf .bundle/plugin`.) +# * nothing fires on `bundle exec` / `bundle check` / plain `ruby`, and +# `gem pristine` bypasses bundler entirely — a reversion via those is only +# healed at the NEXT `bundle install`. # -# On any patch failure it raises Bundler::BundlerError so the build breaks -# loudly rather than proceeding with stale/unpatched gems. The socket-patch CLI -# must be on PATH (or pointed at by SOCKET_PATCH_BIN) wherever `bundle install` -# runs — the same requirement as the cargo build-script guard. +# A digest of (manifest + every committed file under .socket/ + Gemfile.lock + +# the on-disk content of every gem-patch target file) gates the non-forced +# triggers: identical to the last applied state -> fast exit; otherwise shell +# out and re-stamp. Folding the targets' actual content in means an +# out-of-band reversion (pristine, manual edit, wiped bundle path) flips the +# digest even when every committed input is byte-identical. The stamp is a +# pure digest cache at .socket/gem-plugin-stamp — machine-local state that +# `setup` keeps out of version control via .socket/.gitignore. Deleting it +# only forces one re-probe, and a stale copy that reaches version control +# anyway is harmless on a fresh clone: the bootstrap gate below keys on the +# patch targets existing on disk, never on the stamp. Older plugin versions +# stamped a fixed-name file under Bundler.bundle_path — the machine-global +# gem dir when no bundle path is configured, shared and clobbered across +# every project on the host — so that legacy stamp is deleted best-effort +# when seen. +# +# A patch failure NEVER breaks `bundle install`: it prints a warning naming +# what failed and the remediation (run `socket-patch apply --ecosystems gem` +# manually). Set SOCKET_PATCH_STRICT=1 to restore raise-on-failure +# (Bundler::BundlerError) for builds that must not proceed with unpatched +# gems. The socket-patch CLI must be on PATH (or pointed at by +# SOCKET_PATCH_BIN) wherever `bundle install` runs. require "digest" require "fileutils" +require "json" module SocketPatch - BIN_ENV = "SOCKET_PATCH_BIN".freeze - STAMP_NAME = ".socket-patch-gem-stamp".freeze + # Bundler evaluates this file twice in a bootstrap install (registration + + # first hook load), so constant assignments are guarded against re-runs. + BIN_ENV = "SOCKET_PATCH_BIN".freeze unless defined?(BIN_ENV) + STRICT_ENV = "SOCKET_PATCH_STRICT".freeze unless defined?(STRICT_ENV) + STAMP_NAME = "gem-plugin-stamp".freeze unless defined?(STAMP_NAME) + LEGACY_STAMP_NAME = ".socket-patch-gem-stamp".freeze unless defined?(LEGACY_STAMP_NAME) + # Bundler's parallel installer can fire per-gem hooks from worker threads; + # one applier runs at a time so a single bundle process never races + # concurrent `socket-patch apply` children against each other. + APPLY_LOCK = Mutex.new unless defined?(APPLY_LOCK) module_function @@ -43,8 +74,12 @@ module SocketPatch File.expand_path("../..", __dir__) end + def socket_dir + File.join(project_root, ".socket") + end + def manifest_path - File.join(project_root, ".socket", "manifest.json") + File.join(socket_dir, "manifest.json") end def socket_bin @@ -52,18 +87,71 @@ module SocketPatch env && !env.empty? ? env : "socket-patch" end + def strict? + %w[1 true].include?(ENV[STRICT_ENV].to_s) + end + + def bundle_path + Bundler.bundle_path.to_s + rescue StandardError + File.join(project_root, "vendor", "bundle") + end + + def stamp_path + File.join(socket_dir, STAMP_NAME) + end + + # The on-disk files the manifest's gem patches target: + # /gems/-[-]/. + # Paths are collected whether or not the file exists — `current_digest` + # folds an absence marker, so a gem appearing or vanishing flips the digest. + def patch_target_files + records = begin + JSON.parse(File.read(manifest_path)).fetch("patches", {}) + rescue StandardError + return [] + end + return [] unless records.is_a?(Hash) + gems_dir = File.join(bundle_path, "gems") + # Dir.glob treats `\` as an escape on EVERY platform, so a Windows-style + # bundle path (Bundler.bundle_path carries backslash separators through + # verbatim) would never match the platform-gem wildcard below: platform + # installs (nokogiri-1.15.0-x64-mingw-ucrt) drop out of the digest and a + # `bundle pristine` reversion of them leaves the stamp matching. Forward + # slashes are valid separators on Windows, so normalize the GLOB BASE + # only — the direct join below is not a pattern and stays byte-faithful. + glob_gems_dir = gems_dir.tr("\\", "/") + targets = [] + records.each do |purl, record| + next unless purl.is_a?(String) && purl.start_with?("pkg:gem/") + coordinate = purl.split("pkg:gem/", 2).last.split("?", 2).first + name, at, version = coordinate.rpartition("@") + next if at.empty? || name.empty? || version.empty? + files = record.is_a?(Hash) ? record["files"] : nil + next unless files.is_a?(Hash) + files.each_key do |key| + rel = key.to_s.sub(%r{\Apackage/}, "") + targets << File.join(gems_dir, "#{name}-#{version}", rel) + targets.concat(Dir.glob(File.join(glob_gems_dir, "#{name}-#{version}-*", rel))) + end + end + targets.uniq.sort + end + # Files whose change must force a reapply: the manifest, every committed file - # under .socket/ (patch blobs etc.), and Gemfile.lock. + # under .socket/ (patch blobs etc. — the stamp itself excluded, or each write + # would invalidate the digest it records), Gemfile.lock, and the current + # on-disk state of every patch target. def digest_inputs inputs = [manifest_path] lock = File.join(project_root, "Gemfile.lock") inputs << lock if File.file?(lock) - socket_dir = File.join(project_root, ".socket") if File.directory?(socket_dir) Dir.glob(File.join(socket_dir, "**", "*")).sort.each do |p| - inputs << p if File.file?(p) + inputs << p if File.file?(p) && p != stamp_path end end + inputs.concat(patch_target_files) inputs.uniq end @@ -72,27 +160,18 @@ module SocketPatch digest_inputs.each do |path| d.update(path) d.update("\0") + d.update(File.file?(path) ? "+" : "-") begin d.update(File.binread(path)) rescue StandardError - # Unreadable now -> contributes only its path; a later readable state - # changes the digest and forces a reapply. + # Unreadable now -> contributes only its path + absence marker; a later + # readable state changes the digest and forces a reapply. end d.update("\0") end d.hexdigest end - def bundle_path - Bundler.bundle_path.to_s - rescue StandardError - File.join(project_root, "vendor", "bundle") - end - - def stamp_path - File.join(bundle_path, STAMP_NAME) - end - def stamped?(digest) File.file?(stamp_path) && File.read(stamp_path).strip == digest rescue StandardError @@ -106,56 +185,122 @@ module SocketPatch # Best-effort: a missing/unwritable stamp just means we re-probe next time. end - def fail!(message) - raise(defined?(Bundler::BundlerError) ? Bundler::BundlerError.new(message) : message) + # Older plugin versions stamped under Bundler.bundle_path. It is never read + # anymore; delete it (best-effort, once per process) so it does not linger + # as an orphan in a shared gem dir. + def remove_legacy_stamp + return if @legacy_stamp_checked + @legacy_stamp_checked = true + legacy = File.join(bundle_path, LEGACY_STAMP_NAME) + File.delete(legacy) if File.file?(legacy) + rescue StandardError + # Best-effort cleanup only. end - # Idempotent, missing-gem-tolerant. No manifest -> the project does not use - # socket-patch, nothing to do. When `force` is false the digest stamp short- - # circuits already-applied state; the after-install-all hook passes force:true - # because the installer just changed the on-disk gem set. - def apply!(force: false) - return unless File.file?(manifest_path) + # Tolerant by default: a patch failure must never break `bundle install` — + # the first install of a fresh checkout runs the applier before any project + # gem exists, and raising there deadlocks the project on its own bootstrap + # (plugin registration fails, so every retry fails identically). Warn once + # per process with the remediation; SOCKET_PATCH_STRICT=1 restores the raise + # for builds that must not proceed unpatched. The trailer states what the + # ACTIVE mode does — the strict raise must not claim the install continues. + def failure_trailer + if strict? + "Failing `bundle install` because #{STRICT_ENV} is set; unset it to " \ + "warn and continue instead." + else + "`bundle install` continues; set #{STRICT_ENV}=1 to make patch " \ + "failures fatal." + end + end - digest = current_digest - return if !force && stamped?(digest) + def report_failure(message) + message = "#{message} #{failure_trailer}" + if strict? + raise(defined?(Bundler::BundlerError) ? Bundler::BundlerError.new(message) : message) + end + return if @warned + @warned = true + warn(message) + end - ok = system( - socket_bin, "apply", - "--ecosystems", "gem", "--offline", "--silent", - "--cwd", project_root - ) + # Idempotent applier behind every trigger. No manifest -> the project does + # not use socket-patch, nothing to do. + # force: skip the digest gate (the installer just changed the gem set). + # bootstrap_gate: bail while NONE of the manifest's gem-patch targets exist + # on disk. The load-time trigger and the per-gem after-install hook use it + # so a bootstrap install's early evaluations (plugin REGISTRATION runs + # before any project gem lands) never shell out, warn, or — in strict mode — + # raise while there is nothing to patch; the forced after-install-all pass + # does the first real apply once the gems exist. The gate reads only the + # live gem tree, never the stamp: a stale stamp committed by mistake cannot + # re-open the bootstrap deadlock on a fresh clone, and deleting the stamp + # costs one re-probe instead of disabling these triggers. + def apply!(force: false, bootstrap_gate: false) + APPLY_LOCK.synchronize do + return unless File.file?(manifest_path) + remove_legacy_stamp + return if bootstrap_gate && patch_target_files.none? { |t| File.file?(t) } + return if !force && stamped?(current_digest) - if ok.nil? - fail!( - "socket-patch: could not run `#{socket_bin} apply` to apply gem patches; " \ - "the socket-patch CLI is required. Install it or set #{BIN_ENV} to its path." - ) - elsif !ok - fail!( - "socket-patch: `#{socket_bin} apply --ecosystems gem` failed; the gem patches " \ - "in .socket/manifest.json are NOT applied. The build was failed to avoid " \ - "shipping unpatched gems." + ok = system( + socket_bin, "apply", + "--ecosystems", "gem", "--offline", "--silent", + "--cwd", project_root ) - end - write_stamp(digest) + if ok.nil? + report_failure( + "socket-patch: could not run `#{socket_bin} apply` — the gem patches in " \ + ".socket/manifest.json are NOT applied. Install the socket-patch CLI (or set " \ + "#{BIN_ENV} to its path), then run `socket-patch apply --ecosystems gem` " \ + "manually." + ) + return + elsif !ok + report_failure( + "socket-patch: `#{socket_bin} apply --ecosystems gem` failed — the gem patches " \ + "in .socket/manifest.json may NOT be applied. Run `socket-patch apply " \ + "--ecosystems gem` in #{project_root} to apply them manually." + ) + return + end + + if @warned + @warned = false + warn("socket-patch: gem patches applied; the earlier warning is resolved.") + end + # Recompute: the apply just rewrote the target files the digest folds in. + write_stamp(current_digest) + end end end -# Trigger 1 — load-time (covers the cached/no-op `bundle install`). On a fresh -# install the gems are not on disk yet; `apply!` is a tolerant no-op there and -# Trigger 2 does the real work once they exist. A genuine patch failure -# (Bundler::BundlerError) still propagates. +# Trigger 1 — load time. Runs at plugin registration and whenever a subscribed +# hook event first loads the plugin in a bundle process. Bootstrap-gated on +# the patch targets existing on disk (never on the stamp — a committed stale +# stamp must not re-open the registration deadlock): on the bootstrap install +# no gems exist to patch, so this quietly defers to Trigger 3. In strict mode +# a genuine patch failure (Bundler::BundlerError) still propagates. begin - SocketPatch.apply! + SocketPatch.apply!(bootstrap_gate: true) rescue StandardError => e raise if defined?(Bundler::BundlerError) && e.is_a?(Bundler::BundlerError) end -# Trigger 2 — after the installer finishes (covers the fresh install). Forced, -# because the install just changed the gem set; the applier is idempotent so a -# redundant run on an already-patched tree is a cheap no-op. +# Trigger 2 — after each individual gem (re)install. The only event bundler +# fires during `bundle pristine`, so this is what catches pristine's patch +# reversion in the same run — even when the stamp was deleted, since the gate +# reads the gem tree, not the stamp. Digest- and bootstrap-gated: on a fresh +# install's per-gem events the targets are only just landing and Trigger 3 is +# about to do the real work. +Bundler::Plugin.add_hook("after-install") do |_spec_install| + SocketPatch.apply!(bootstrap_gate: true) +end + +# Trigger 3 — after the installer finishes (fresh AND fully-cached installs). +# Forced, because the install just changed the gem set; the applier is +# idempotent so a redundant run on an already-patched tree is a cheap no-op. Bundler::Plugin.add_hook("after-install-all") do |_install| SocketPatch.apply!(force: true) end diff --git a/gem/socket-patch-bundler/plugins.rb b/gem/socket-patch-bundler/plugins.rb index f2d65fcd..32b3bfdb 100644 --- a/gem/socket-patch-bundler/plugins.rb +++ b/gem/socket-patch-bundler/plugins.rb @@ -1,32 +1,64 @@ # socket-patch Bundler plugin (published-gem form). # -# Keeps the gem patches recorded in .socket/manifest.json applied on every -# `bundle install` — including a cached/no-op install — by re-running the -# socket-patch CLI. This is the Phase-2 published-gem counterpart of the in-tree -# plugin generated by `socket-patch setup` under .socket/bundler-plugin/; the -# applier logic is identical, but because a published plugin is loaded from the -# gem cache (not from inside the repo) it resolves the project root from the -# bundle context rather than relative to its own location. +# Keeps the gem patches recorded in .socket/manifest.json applied by +# re-running the socket-patch CLI whenever Bundler touches the gem set. This +# is the Phase-2 published-gem counterpart of the in-tree plugin generated by +# `socket-patch setup` under .socket/bundler-plugin/; the applier logic is +# identical, but because a published plugin is loaded from the gem cache (not +# from inside the repo) it resolves the project root from the bundle context +# rather than relative to its own location. # -# Two complementary triggers feed one idempotent applier: -# * load-time — evaluated during Bundler's Gemfile pass on EVERY `bundle` -# invocation, covering the cached/no-op install the after-install-all hook -# would miss; -# * the `after-install-all` hook — fires after the installer finishes, -# covering the fresh install where gems exist only afterwards. +# When each trigger actually runs (verified against bundler 2.7 and 4.0; hook +# subscriptions are recorded in .bundle/plugin/index at plugin REGISTRATION, +# and bundler evaluates this file whenever a subscribed event first fires in a +# bundle process): # -# A digest of (manifest + every committed file under .socket/ + Gemfile.lock) -# gates the load-time work. The stamp lives under Bundler.bundle_path so it -# travels WITH the gems. On any patch failure it raises Bundler::BundlerError so -# the build breaks loudly rather than shipping stale/unpatched gems. The -# socket-patch CLI must be on PATH (or pointed at by SOCKET_PATCH_BIN). +# * plugin registration — the FIRST `bundle install` evaluates this file +# BEFORE any project gem is installed, so the load-time trigger is +# bootstrap-gated (no patch target exists yet) and quietly no-ops there; +# the install hooks below re-apply once the gems land. +# * every `bundle install` — fresh AND fully cached — fires the per-gem +# `after-install` events and then `after-install-all`; the forced +# `after-install-all` re-apply is the actual patch point. +# * `bundle pristine` fires ONLY the per-gem `after-install` events, so that +# digest-gated hook is what catches pristine's patch reversion in the same +# run. (A checkout registered by an older plugin version keeps its old +# subscription set until it re-registers — fresh clones and CI always +# re-register, a dev checkout can `rm -rf .bundle/plugin`.) +# * nothing fires on `bundle exec` / `bundle check` / plain `ruby`, and +# `gem pristine` bypasses bundler entirely — a reversion via those is only +# healed at the NEXT `bundle install`. +# +# A digest of (manifest + every committed file under .socket/ + Gemfile.lock + +# the on-disk content of every gem-patch target file) gates the non-forced +# triggers. The stamp is a pure digest cache at .socket/gem-plugin-stamp — +# machine-local state, safe to gitignore or delete (deleting only forces one +# re-probe); a stale copy that reaches version control anyway is harmless on +# a fresh clone, because the bootstrap gate keys on the patch targets +# existing on disk, never on the stamp. Older plugin versions stamped a +# fixed-name file under Bundler.bundle_path (machine-global when no bundle +# path is configured); that legacy stamp is deleted best-effort when seen. +# +# A patch failure NEVER breaks `bundle install`: it prints a warning naming +# what failed and the remediation. Set SOCKET_PATCH_STRICT=1 to restore +# raise-on-failure (Bundler::BundlerError). The socket-patch CLI must be on +# PATH (or pointed at by SOCKET_PATCH_BIN). require "digest" require "fileutils" +require "json" module SocketPatch - BIN_ENV = "SOCKET_PATCH_BIN".freeze - STAMP_NAME = ".socket-patch-gem-stamp".freeze + # Bundler evaluates this file twice in a bootstrap install (registration + + # first hook load), so constant assignments are guarded against re-runs. + BIN_ENV = "SOCKET_PATCH_BIN".freeze unless defined?(BIN_ENV) + STRICT_ENV = "SOCKET_PATCH_STRICT".freeze unless defined?(STRICT_ENV) + STAMP_NAME = "gem-plugin-stamp".freeze unless defined?(STAMP_NAME) + LEGACY_STAMP_NAME = ".socket-patch-gem-stamp".freeze unless defined?(LEGACY_STAMP_NAME) + # Bundler's parallel installer can fire per-gem hooks from worker threads; + # one applier runs at a time so a single bundle process never races + # concurrent `socket-patch apply` children against each other. + APPLY_LOCK = Mutex.new unless defined?(APPLY_LOCK) module_function @@ -50,8 +82,12 @@ def project_root Dir.pwd end + def socket_dir + File.join(project_root, ".socket") + end + def manifest_path - File.join(project_root, ".socket", "manifest.json") + File.join(socket_dir, "manifest.json") end def socket_bin @@ -59,18 +95,71 @@ def socket_bin env && !env.empty? ? env : "socket-patch" end + def strict? + %w[1 true].include?(ENV[STRICT_ENV].to_s) + end + + def bundle_path + Bundler.bundle_path.to_s + rescue StandardError + File.join(project_root, "vendor", "bundle") + end + + def stamp_path + File.join(socket_dir, STAMP_NAME) + end + + # The on-disk files the manifest's gem patches target: + # /gems/-[-]/. + # Paths are collected whether or not the file exists — `current_digest` + # folds an absence marker, so a gem appearing or vanishing flips the digest. + def patch_target_files + records = begin + JSON.parse(File.read(manifest_path)).fetch("patches", {}) + rescue StandardError + return [] + end + return [] unless records.is_a?(Hash) + gems_dir = File.join(bundle_path, "gems") + # Dir.glob treats `\` as an escape on EVERY platform, so a Windows-style + # bundle path (Bundler.bundle_path carries backslash separators through + # verbatim) would never match the platform-gem wildcard below: platform + # installs (nokogiri-1.15.0-x64-mingw-ucrt) drop out of the digest and a + # `bundle pristine` reversion of them leaves the stamp matching. Forward + # slashes are valid separators on Windows, so normalize the GLOB BASE + # only — the direct join below is not a pattern and stays byte-faithful. + glob_gems_dir = gems_dir.tr("\\", "/") + targets = [] + records.each do |purl, record| + next unless purl.is_a?(String) && purl.start_with?("pkg:gem/") + coordinate = purl.split("pkg:gem/", 2).last.split("?", 2).first + name, at, version = coordinate.rpartition("@") + next if at.empty? || name.empty? || version.empty? + files = record.is_a?(Hash) ? record["files"] : nil + next unless files.is_a?(Hash) + files.each_key do |key| + rel = key.to_s.sub(%r{\Apackage/}, "") + targets << File.join(gems_dir, "#{name}-#{version}", rel) + targets.concat(Dir.glob(File.join(glob_gems_dir, "#{name}-#{version}-*", rel))) + end + end + targets.uniq.sort + end + # Files whose change must force a reapply: the manifest, every committed file - # under .socket/ (patch blobs etc.), and Gemfile.lock. + # under .socket/ (patch blobs etc. — the stamp itself excluded, or each write + # would invalidate the digest it records), Gemfile.lock, and the current + # on-disk state of every patch target. def digest_inputs inputs = [manifest_path] lock = File.join(project_root, "Gemfile.lock") inputs << lock if File.file?(lock) - socket_dir = File.join(project_root, ".socket") if File.directory?(socket_dir) Dir.glob(File.join(socket_dir, "**", "*")).sort.each do |p| - inputs << p if File.file?(p) + inputs << p if File.file?(p) && p != stamp_path end end + inputs.concat(patch_target_files) inputs.uniq end @@ -79,27 +168,18 @@ def current_digest digest_inputs.each do |path| d.update(path) d.update("\0") + d.update(File.file?(path) ? "+" : "-") begin d.update(File.binread(path)) rescue StandardError - # Unreadable now -> contributes only its path; a later readable state - # changes the digest and forces a reapply. + # Unreadable now -> contributes only its path + absence marker; a later + # readable state changes the digest and forces a reapply. end d.update("\0") end d.hexdigest end - def bundle_path - Bundler.bundle_path.to_s - rescue StandardError - File.join(project_root, "vendor", "bundle") - end - - def stamp_path - File.join(bundle_path, STAMP_NAME) - end - def stamped?(digest) File.file?(stamp_path) && File.read(stamp_path).strip == digest rescue StandardError @@ -113,52 +193,122 @@ def write_stamp(digest) # Best-effort: a missing/unwritable stamp just means we re-probe next time. end - def fail!(message) - raise(defined?(Bundler::BundlerError) ? Bundler::BundlerError.new(message) : message) + # Older plugin versions stamped under Bundler.bundle_path. It is never read + # anymore; delete it (best-effort, once per process) so it does not linger + # as an orphan in a shared gem dir. + def remove_legacy_stamp + return if @legacy_stamp_checked + @legacy_stamp_checked = true + legacy = File.join(bundle_path, LEGACY_STAMP_NAME) + File.delete(legacy) if File.file?(legacy) + rescue StandardError + # Best-effort cleanup only. end - # Idempotent, missing-gem-tolerant. No manifest -> the project does not use - # socket-patch, nothing to do. When `force` is false the digest stamp short- - # circuits already-applied state; the after-install-all hook passes force:true - # because the installer just changed the on-disk gem set. - def apply!(force: false) - return unless File.file?(manifest_path) + # Tolerant by default: a patch failure must never break `bundle install` — + # the first install of a fresh checkout runs the applier before any project + # gem exists, and raising there deadlocks the project on its own bootstrap + # (plugin registration fails, so every retry fails identically). Warn once + # per process with the remediation; SOCKET_PATCH_STRICT=1 restores the raise + # for builds that must not proceed unpatched. The trailer states what the + # ACTIVE mode does — the strict raise must not claim the install continues. + def failure_trailer + if strict? + "Failing `bundle install` because #{STRICT_ENV} is set; unset it to " \ + "warn and continue instead." + else + "`bundle install` continues; set #{STRICT_ENV}=1 to make patch " \ + "failures fatal." + end + end - digest = current_digest - return if !force && stamped?(digest) + def report_failure(message) + message = "#{message} #{failure_trailer}" + if strict? + raise(defined?(Bundler::BundlerError) ? Bundler::BundlerError.new(message) : message) + end + return if @warned + @warned = true + warn(message) + end - ok = system( - socket_bin, "apply", - "--ecosystems", "gem", "--offline", "--silent", - "--cwd", project_root - ) + # Idempotent applier behind every trigger. No manifest -> the project does + # not use socket-patch, nothing to do. + # force: skip the digest gate (the installer just changed the gem set). + # bootstrap_gate: bail while NONE of the manifest's gem-patch targets exist + # on disk. The load-time trigger and the per-gem after-install hook use it + # so a bootstrap install's early evaluations (plugin REGISTRATION runs + # before any project gem lands) never shell out, warn, or — in strict mode — + # raise while there is nothing to patch; the forced after-install-all pass + # does the first real apply once the gems exist. The gate reads only the + # live gem tree, never the stamp: a stale stamp committed by mistake cannot + # re-open the bootstrap deadlock on a fresh clone, and deleting the stamp + # costs one re-probe instead of disabling these triggers. + def apply!(force: false, bootstrap_gate: false) + APPLY_LOCK.synchronize do + return unless File.file?(manifest_path) + remove_legacy_stamp + return if bootstrap_gate && patch_target_files.none? { |t| File.file?(t) } + return if !force && stamped?(current_digest) - if ok.nil? - fail!( - "socket-patch: could not run `#{socket_bin} apply` to apply gem patches; " \ - "the socket-patch CLI is required. Install it or set #{BIN_ENV} to its path." - ) - elsif !ok - fail!( - "socket-patch: `#{socket_bin} apply --ecosystems gem` failed; the gem patches " \ - "in .socket/manifest.json are NOT applied. The build was failed to avoid " \ - "shipping unpatched gems." + ok = system( + socket_bin, "apply", + "--ecosystems", "gem", "--offline", "--silent", + "--cwd", project_root ) - end - write_stamp(digest) + if ok.nil? + report_failure( + "socket-patch: could not run `#{socket_bin} apply` — the gem patches in " \ + ".socket/manifest.json are NOT applied. Install the socket-patch CLI (or set " \ + "#{BIN_ENV} to its path), then run `socket-patch apply --ecosystems gem` " \ + "manually." + ) + return + elsif !ok + report_failure( + "socket-patch: `#{socket_bin} apply --ecosystems gem` failed — the gem patches " \ + "in .socket/manifest.json may NOT be applied. Run `socket-patch apply " \ + "--ecosystems gem` in #{project_root} to apply them manually." + ) + return + end + + if @warned + @warned = false + warn("socket-patch: gem patches applied; the earlier warning is resolved.") + end + # Recompute: the apply just rewrote the target files the digest folds in. + write_stamp(current_digest) + end end end -# Trigger 1 — load-time (covers the cached/no-op `bundle install`). +# Trigger 1 — load time. Runs at plugin registration and whenever a subscribed +# hook event first loads the plugin in a bundle process. Bootstrap-gated on +# the patch targets existing on disk (never on the stamp — a committed stale +# stamp must not re-open the registration deadlock): on the bootstrap install +# no gems exist to patch, so this quietly defers to Trigger 3. In strict mode +# a genuine patch failure (Bundler::BundlerError) still propagates. begin - SocketPatch.apply! + SocketPatch.apply!(bootstrap_gate: true) rescue StandardError => e raise if defined?(Bundler::BundlerError) && e.is_a?(Bundler::BundlerError) end -# Trigger 2 — after the installer finishes (covers the fresh install). Forced, -# because the install just changed the gem set; the applier is idempotent. +# Trigger 2 — after each individual gem (re)install. The only event bundler +# fires during `bundle pristine`, so this is what catches pristine's patch +# reversion in the same run — even when the stamp was deleted, since the gate +# reads the gem tree, not the stamp. Digest- and bootstrap-gated: on a fresh +# install's per-gem events the targets are only just landing and Trigger 3 is +# about to do the real work. +Bundler::Plugin.add_hook("after-install") do |_spec_install| + SocketPatch.apply!(bootstrap_gate: true) +end + +# Trigger 3 — after the installer finishes (fresh AND fully-cached installs). +# Forced, because the install just changed the gem set; the applier is +# idempotent so a redundant run on an already-patched tree is a cheap no-op. Bundler::Plugin.add_hook("after-install-all") do |_install| SocketPatch.apply!(force: true) end diff --git a/gem/socket-patch-bundler/socket-patch-bundler.gemspec b/gem/socket-patch-bundler/socket-patch-bundler.gemspec index bf130b4e..01359d65 100644 --- a/gem/socket-patch-bundler/socket-patch-bundler.gemspec +++ b/gem/socket-patch-bundler/socket-patch-bundler.gemspec @@ -2,7 +2,7 @@ # Published form of the socket-patch Bundler plugin (CLI_CONTRACT property: # "gem" support matrix, Phase 2). `socket-patch setup` today references the -# in-tree plugin under `.socket/bundler-plugin/` via `git:`; once this gem is +# in-tree plugin under `.socket/bundler-plugin/` via `path:`; once this gem is # published, a follow-up switches the Gemfile directive to # `plugin "socket-patch-bundler", "~> "`. The version is kept in # sync with the workspace by `scripts/version-sync.sh`. diff --git a/gem/socket-patch/lib/socket_patch/launcher.rb b/gem/socket-patch/lib/socket_patch/launcher.rb index bed9b1db..0b0a706f 100644 --- a/gem/socket-patch/lib/socket_patch/launcher.rb +++ b/gem/socket-patch/lib/socket_patch/launcher.rb @@ -31,14 +31,24 @@ def run(argv) bin = resolve_binary if Gem.win_platform? # Windows has no exec() that replaces the process cleanly for console - # apps; spawn + wait and propagate the child's exit status. - exit(system(bin, *argv) ? $?.exitstatus : 1) + # apps; spawn + wait and propagate the child's real exit status (a + # blanket 1 would erase the CLI's meaningful non-zero codes, e.g. + # `setup --check`'s needs-configuration signal). + ok = system(bin, *argv) + raise LauncherError, "could not run #{bin}" if ok.nil? + exit($?.exitstatus || 1) else exec([bin, bin], *argv) end rescue LauncherError => e warn("socket-patch: #{e.message}") exit(1) + rescue StandardError => e + # First-run download/extract failures outside our own error type (DNS + # outages, TLS errors, ...) must exit cleanly, not escape as raw + # backtraces. + warn("socket-patch: #{e.class}: #{e.message}") + exit(1) end class LauncherError < StandardError; end @@ -74,7 +84,10 @@ def version return spec.version.to_s end Gem::Specification.find_by_name("socket-patch").version.to_s - rescue StandardError + rescue StandardError, Gem::LoadError + # Gem::MissingSpecError (the gem isn't installed at all — running from + # a checkout) is a Gem::LoadError, which is NOT a StandardError; without + # naming it the documented fallback never engaged. VERSION end @@ -152,9 +165,33 @@ def download_binary(ver, target, ext, dest) raise LauncherError, "release archive #{archive} did not contain #{exe}" end - FileUtils.mkdir_p(File.dirname(dest)) - FileUtils.cp(extracted, dest) - File.chmod(0o755, dest) unless Gem.win_platform? + install_executable(extracted, dest) + end + end + + # Publish the verified binary into the cache atomically: copy to a temp + # file in the destination dir, set the exec bit, then rename over the + # final path — a concurrent first run can only ever see a complete, + # executable binary, never a torn or not-yet-chmodded one. + def install_executable(src, dest) + FileUtils.mkdir_p(File.dirname(dest)) + tmp = File.join(File.dirname(dest), ".#{File.basename(dest)}.#{Process.pid}.tmp") + begin + FileUtils.cp(src, tmp) + File.chmod(0o755, tmp) unless Gem.win_platform? + begin + File.rename(tmp, dest) + rescue SystemCallError + # Windows rename cannot replace an existing file: a concurrent + # first run already published the (identical, verified) binary. + raise unless File.exist?(dest) + end + ensure + begin + File.delete(tmp) if File.file?(tmp) + rescue StandardError + # Leftover temp cleanup is best-effort. + end end end @@ -223,6 +260,14 @@ def verify_sha256!(path, archive, sums) raise LauncherError, "checksum mismatch for #{archive} (expected #{expected}, got #{actual})" end + # Quote a value for interpolation into a PowerShell command: single-quoted + # strings are literal except for embedded single quotes, which are escaped + # by doubling them (paths like `it's here` would otherwise break the + # command). + def powershell_quote(value) + "'#{value.gsub("'", "''")}'" + end + def extract(archive_path, ext, dir) ok = if ext == "zip" @@ -230,7 +275,8 @@ def extract(archive_path, ext, dir) # PowerShell Expand-Archive. system("tar", "-xf", archive_path, "-C", dir) || system("powershell", "-NoProfile", "-Command", - "Expand-Archive -Force -LiteralPath '#{archive_path}' -DestinationPath '#{dir}'") + "Expand-Archive -Force -LiteralPath #{powershell_quote(archive_path)} " \ + "-DestinationPath #{powershell_quote(dir)}") else system("tar", "xzf", archive_path, "-C", dir) end diff --git a/tests/setup_matrix/matrix.json b/tests/setup_matrix/matrix.json index c5b4c308..101316fe 100644 --- a/tests/setup_matrix/matrix.json +++ b/tests/setup_matrix/matrix.json @@ -155,8 +155,8 @@ }, { - "ecosystem": "gem", "pm": "bundler", "image": "gem", "hook_family": "none", - "baseline_supported": false, + "ecosystem": "gem", "pm": "bundler", "image": "gem", "hook_family": "bundler-plugin", + "baseline_supported": true, "package": "colorize", "version": "1.1.0", "purl": "pkg:gem/colorize@1.1.0", "manifest_key": "package/lib/colorize.rb", "apply_ecosystems": "gem" }, diff --git a/tests/setup_matrix/run-case.sh b/tests/setup_matrix/run-case.sh index c399b675..b4713c3a 100755 --- a/tests/setup_matrix/run-case.sh +++ b/tests/setup_matrix/run-case.sh @@ -150,14 +150,15 @@ marker_blob() { # $1 = marker -> runnable payload on stdout esac } -write_manifest() { # $1=purl $2=key $3=afterHash +write_manifest() { # $1=purl $2=key $3=afterHash $4=beforeHash (default: zero) + local before="${4:-$ZEROHASH}" cat > .socket/manifest.json </dev/null 2>&1 \ + && gem unpack "${SM_PACKAGE}-${SM_VERSION}.gem" >/dev/null 2>&1) \ + && [ -f "$target" ]; then + git_sha256 "$target" + else + # The function runs inside $(...): route the log PAST the capture pipe. + log "gem beforeHash probe failed; falling back to the zero placeholder" >&2 + printf '%s' "$ZEROHASH" + fi + rm -rf "$dir" +} + build_fixture() { # Ablation: no patch set committed at all (no .socket/). Even with a # working install hook, apply finds no manifest and no-ops, so the @@ -196,11 +226,11 @@ build_fixture() { alt) marker_blob "$SM_ALT_MARKER" > "$blob_tmp" local h; h="$(git_sha256 "$blob_tmp")"; cp "$blob_tmp" ".socket/blobs/$h" - write_manifest "$SM_PURL" "$SM_MANIFEST_KEY" "$h" ;; + write_manifest "$SM_PURL" "$SM_MANIFEST_KEY" "$h" "$(resolve_before_hash)" ;; *) # primary marker_blob "$SM_MARKER" > "$blob_tmp" local h; h="$(git_sha256 "$blob_tmp")"; cp "$blob_tmp" ".socket/blobs/$h" - write_manifest "$SM_PURL" "$SM_MANIFEST_KEY" "$h" ;; + write_manifest "$SM_PURL" "$SM_MANIFEST_KEY" "$h" "$(resolve_before_hash)" ;; esac rm -f "$blob_tmp" }