From 47df632b1504602289f2e37e672ca56f906af978 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 06:27:37 +0000 Subject: [PATCH 1/8] Start fix for #341, #390 Assisted-by: Claude Code:claude-opus-5-5 From cd44f106621b4f917a684dfe2a7dcfba8f331b20 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 06:36:32 +0000 Subject: [PATCH 2/8] Refuse gem vendoring when Bundler loads gems.rb Vendored gem mode always wired Gemfile and Gemfile.lock. When Bundler loads a different manifest (a gems.rb twin, or BUNDLE_GEMFILE set in the environment or in .bundle/config), vendor reported success while Bundler installed the unpatched gem, and VEX then attested it. Add one resolver for the manifest Bundler loads, following Bundler's own order, and refuse vendoring before any write unless that manifest is the Gemfile. Refs #341, #390 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/ruby_crawler.rs | 4 +- .../src/formats/gem/manifest.rs | 282 ++++++++++++++++++ .../socket-patch-core/src/formats/gem/mod.rs | 1 + crates/socket-patch-core/src/vendor/gem.rs | 111 +++++++ 4 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 crates/socket-patch-core/src/formats/gem/manifest.rs diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 8c2a1744e..f3dc7292b 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -948,7 +948,7 @@ fn expand_tilde(value: &Path, home: Option<&Path>) -> PathBuf { /// exactly: `$BUNDLE_APP_CONFIG` when set (a relative value resolves against /// the project root, NOT the process cwd), else `/.bundle` — e.g. the /// official ruby Docker images export `BUNDLE_APP_CONFIG=/usr/local/bundle`. -fn bundler_app_config_dir(root: &Path, env_value: Option<&OsStr>) -> PathBuf { +pub(crate) fn bundler_app_config_dir(root: &Path, env_value: Option<&OsStr>) -> PathBuf { match env_value { Some(v) if !v.is_empty() => { let p = PathBuf::from(v); @@ -1070,7 +1070,7 @@ fn parse_bundle_config_path(contents: &str) -> Option { /// Unwrap one bundler app-config scalar: trim, then strip one matching /// pair of double or single quotes (bundler double-quotes what it writes). -fn unquote_bundle_config_value(rest: &str) -> &str { +pub(crate) fn unquote_bundle_config_value(rest: &str) -> &str { let v = rest.trim(); v.strip_prefix('"') .and_then(|s| s.strip_suffix('"')) diff --git a/crates/socket-patch-core/src/formats/gem/manifest.rs b/crates/socket-patch-core/src/formats/gem/manifest.rs new file mode 100644 index 000000000..e9601f446 --- /dev/null +++ b/crates/socket-patch-core/src/formats/gem/manifest.rs @@ -0,0 +1,282 @@ +//! Which manifest Bundler loads for a project: the ONE answer shared by the +//! hosted rewriter's caller and the vendored backend, so neither can wire a +//! file Bundler ignores. +//! +//! Bundler's own order (`Bundler::SharedHelpers#default_gemfile` and the CLI's +//! `gemfile` setting): +//! +//! 1. `BUNDLE_GEMFILE` from the environment (a relative value expands +//! against the process cwd, as `File.expand_path` does); +//! 2. `BUNDLE_GEMFILE:` in the app config file, `$BUNDLE_APP_CONFIG/config` +//! else `/.bundle/config` (what `bundle config set --local gemfile +//! Gemfile.next` writes; relative to the project root); +//! 3. otherwise `gems.rb` when present, else `Gemfile`. +//! +//! A configured value that names the root's own `Gemfile` or `gems.rb` is +//! that spelling; anything else (`Gemfile.next`, a file in another +//! directory, a missing file) is [`LoadedManifest::Unsupported`]: the +//! rewriters and the lock readers only know the two default pairs, so the +//! callers fail closed rather than wire a manifest Bundler never reads. +//! The user-level `~/.bundle/config` is not consulted. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use crate::crawlers::ruby_crawler::{bundler_app_config_dir, unquote_bundle_config_value}; +use crate::utils::fs::normalize_lexically; + +/// Where a configured `BUNDLE_GEMFILE` came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GemfileSetting { + /// The `BUNDLE_GEMFILE` environment variable. + Env, + /// `BUNDLE_GEMFILE:` in the project's bundler app config file. + AppConfig, +} + +impl GemfileSetting { + /// How the setting is named in warnings and refusals. + pub fn describe(self) -> &'static str { + match self { + GemfileSetting::Env => "the BUNDLE_GEMFILE environment variable", + GemfileSetting::AppConfig => "BUNDLE_GEMFILE in the bundler app config (.bundle/config)", + } + } +} + +/// The manifest Bundler loads for a project root. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LoadedManifest { + /// No `BUNDLE_GEMFILE`: Bundler's default discovery (`gems.rb` first, + /// then `Gemfile`), which callers apply to the files they see. + Default, + /// `BUNDLE_GEMFILE` names the root's own `Gemfile` or `gems.rb`. + Configured { + manifest: &'static str, + by: GemfileSetting, + }, + /// `BUNDLE_GEMFILE` names any other file. + Unsupported { value: String, by: GemfileSetting }, +} + +impl LoadedManifest { + /// The manifest/lock pair Bundler loads, given whether the root holds a + /// `gems.rb`. `None` for [`LoadedManifest::Unsupported`]. + pub fn pair(&self, gems_rb_present: bool) -> Option<(&'static str, &'static str)> { + let manifest = match self { + LoadedManifest::Default if gems_rb_present => "gems.rb", + LoadedManifest::Default => "Gemfile", + LoadedManifest::Configured { manifest, .. } => manifest, + LoadedManifest::Unsupported { .. } => return None, + }; + Some(if manifest == "gems.rb" { + ("gems.rb", "gems.locked") + } else { + ("Gemfile", "Gemfile.lock") + }) + } + + /// The detail line for a caller that refuses an unsupported manifest. + pub fn unsupported_detail(&self) -> Option { + match self { + LoadedManifest::Unsupported { value, by } => Some(format!( + "bundler loads `{value}` ({}), not the project's Gemfile or gems.rb; \ + socket-patch only wires those, so this gem was left untouched (unset \ + BUNDLE_GEMFILE, or point it at the project's Gemfile, and re-run)", + by.describe() + )), + _ => None, + } + } +} + +/// [`classify`] for `root` on disk, reading the ambient `BUNDLE_GEMFILE` / +/// `BUNDLE_APP_CONFIG` and the app config file. +pub async fn loaded_manifest(root: &Path) -> LoadedManifest { + loaded_manifest_with_env( + root, + std::env::var_os("BUNDLE_GEMFILE").as_deref(), + std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), + ) + .await +} + +/// [`loaded_manifest`] with the environment passed explicitly (hermetic +/// tests). +pub async fn loaded_manifest_with_env( + root: &Path, + gemfile_env: Option<&OsStr>, + app_config_env: Option<&OsStr>, +) -> LoadedManifest { + let config = bundler_app_config_dir(root, app_config_env).join("config"); + let config_value = crate::utils::fs::read_regular_to_string(&config) + .await + .ok() + .and_then(|text| config_gemfile(&text)); + let cwd = std::env::current_dir().unwrap_or_default(); + classify(root, &cwd, gemfile_env, config_value.as_deref()) +} + +/// The `BUNDLE_GEMFILE:` value of a bundler app config file (flat YAML that +/// bundler writes itself; an empty value counts as unset). +pub fn config_gemfile(contents: &str) -> Option { + let mut found = None; + for line in contents.lines() { + if let Some(rest) = line.strip_prefix("BUNDLE_GEMFILE:") { + let v = unquote_bundle_config_value(rest); + found = (!v.is_empty()).then(|| v.to_string()); + } + } + found +} + +/// Classify the configured `BUNDLE_GEMFILE` (environment first, then the +/// app config value) against `root`. `cwd` anchors a relative environment +/// value; a relative config value is anchored at `root`. +pub fn classify( + root: &Path, + cwd: &Path, + gemfile_env: Option<&OsStr>, + config_value: Option<&str>, +) -> LoadedManifest { + let (value, base, by) = match gemfile_env.filter(|v| !v.is_empty()) { + Some(v) => (PathBuf::from(v), cwd, GemfileSetting::Env), + None => match config_value.filter(|v| !v.is_empty()) { + Some(v) => (PathBuf::from(v), root, GemfileSetting::AppConfig), + None => return LoadedManifest::Default, + }, + }; + let display = value.display().to_string(); + let absolute = |p: &Path| std::path::absolute(p).ok().and_then(|p| normalize_lexically(&p)); + let target = if value.is_absolute() { + absolute(&value) + } else { + absolute(&base.join(&value)) + }; + let root = absolute(root); + if let (Some(target), Some(root)) = (target, root) { + for manifest in ["Gemfile", "gems.rb"] { + if target == root.join(manifest) { + return LoadedManifest::Configured { manifest, by }; + } + } + } + LoadedManifest::Unsupported { + value: display, + by, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn root() -> PathBuf { + std::path::absolute("/proj").unwrap() + } + + #[test] + fn no_setting_is_bundlers_default_discovery() { + let m = classify(&root(), &root(), None, None); + assert_eq!(m, LoadedManifest::Default); + assert_eq!(m.pair(true), Some(("gems.rb", "gems.locked"))); + assert_eq!(m.pair(false), Some(("Gemfile", "Gemfile.lock"))); + // An empty value is unset, as in bundler. + assert_eq!( + classify(&root(), &root(), Some(OsStr::new("")), Some("")), + LoadedManifest::Default + ); + } + + #[test] + fn config_naming_another_manifest_is_unsupported() { + let m = classify(&root(), &root(), None, Some("Gemfile.next")); + assert_eq!( + m, + LoadedManifest::Unsupported { + value: "Gemfile.next".into(), + by: GemfileSetting::AppConfig + } + ); + assert_eq!(m.pair(false), None); + assert!(m.unsupported_detail().unwrap().contains("Gemfile.next")); + } + + #[test] + fn config_naming_the_default_spellings_selects_that_pair() { + // `bundle config set --local gemfile Gemfile` beside a gems.rb: + // bundler loads Gemfile + Gemfile.lock, not gems.rb. + let m = classify(&root(), &root(), None, Some("Gemfile")); + assert_eq!(m.pair(true), Some(("Gemfile", "Gemfile.lock"))); + let m = classify(&root(), &root(), None, Some("./gems.rb")); + assert_eq!(m.pair(false), Some(("gems.rb", "gems.locked"))); + let abs = root().join("Gemfile"); + let m = classify(&root(), &root(), None, Some(abs.to_str().unwrap())); + assert_eq!(m.pair(true), Some(("Gemfile", "Gemfile.lock"))); + } + + #[test] + fn env_wins_over_config_and_expands_against_the_cwd() { + let m = classify( + &root(), + &root().join("sub"), + Some(OsStr::new("../Gemfile")), + Some("Gemfile.next"), + ); + assert_eq!( + m, + LoadedManifest::Configured { + manifest: "Gemfile", + by: GemfileSetting::Env + } + ); + let m = classify(&root(), &root(), Some(OsStr::new("Gemfile.next")), None); + assert!(matches!( + m, + LoadedManifest::Unsupported { + by: GemfileSetting::Env, + .. + } + )); + } + + #[test] + fn a_manifest_in_another_directory_is_unsupported() { + let m = classify(&root(), &root(), None, Some("../other/Gemfile")); + assert!(matches!(m, LoadedManifest::Unsupported { .. })); + let m = classify(&root(), &root(), None, Some("sub/Gemfile")); + assert!(matches!(m, LoadedManifest::Unsupported { .. })); + } + + #[test] + fn config_gemfile_reads_bundlers_own_spelling() { + assert_eq!( + config_gemfile("---\nBUNDLE_PATH: \"vendor/bundle\"\nBUNDLE_GEMFILE: \"Gemfile.next\"\n"), + Some("Gemfile.next".into()) + ); + assert_eq!(config_gemfile("---\nBUNDLE_GEMFILE: \"\"\n"), None); + assert_eq!(config_gemfile("---\nBUNDLE_PATH: \"x\"\n"), None); + } + + #[tokio::test] + async fn loaded_manifest_reads_the_app_config_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".bundle")).unwrap(); + std::fs::write( + dir.path().join(".bundle/config"), + "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n", + ) + .unwrap(); + let m = loaded_manifest_with_env(dir.path(), None, None).await; + assert!(matches!( + m, + LoadedManifest::Unsupported { + by: GemfileSetting::AppConfig, + .. + } + )); + // BUNDLE_APP_CONFIG moves the config file away from `.bundle`. + let m = loaded_manifest_with_env(dir.path(), None, Some(OsStr::new("elsewhere"))).await; + assert_eq!(m, LoadedManifest::Default); + } +} diff --git a/crates/socket-patch-core/src/formats/gem/mod.rs b/crates/socket-patch-core/src/formats/gem/mod.rs index a8054fc12..627b5125f 100644 --- a/crates/socket-patch-core/src/formats/gem/mod.rs +++ b/crates/socket-patch-core/src/formats/gem/mod.rs @@ -19,6 +19,7 @@ //! readers that must refuse such a lock. pub(crate) mod hosted; +pub(crate) mod manifest; use std::collections::{BTreeSet, HashMap}; diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 3e65da507..a9c079f70 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -238,6 +238,34 @@ async fn gem_prelude( } // ── project files ──────────────────────────────────────────────────── + // Wire only the manifest bundler actually loads. This backend edits + // the `Gemfile` + `Gemfile.lock` pair, so a project where bundler loads + // `gems.rb` (it wins over a Gemfile twin) or a `BUNDLE_GEMFILE`-configured + // manifest is refused here, before any write: wiring the ignored Gemfile + // would report success while bundler installs the upstream gem. + let loaded = crate::formats::gem::manifest::loaded_manifest(project_root).await; + let gems_rb_present = tokio::fs::symlink_metadata(project_root.join("gems.rb")) + .await + .is_ok(); + match loaded.pair(gems_rb_present) { + Some((GEMFILE, GEMFILE_LOCK)) => {} + Some((manifest, lock)) => { + return Err(refused( + "gemfile_not_loaded", + format!( + "bundler loads {manifest} + {lock}, not the Gemfile + Gemfile.lock pair \ + vendored mode wires (a gems.rb project cannot vendor yet); use hosted \ + mode, or remove gems.rb / gems.locked if the Gemfile is the real manifest" + ), + )); + } + None => { + return Err(refused( + "gemfile_not_loaded", + loaded.unsupported_detail().unwrap_or_default(), + )); + } + } let gemfile_path = project_root.join(GEMFILE); let gemfile_text = match read_regular_to_string(&gemfile_path).await { Ok(t) => t, @@ -2607,6 +2635,89 @@ mod tests { ) } + /// #341: a `gems.rb` beside the Gemfile is the manifest bundler loads + /// ("Multiple gemfiles ... ignoring them in favor of gems.rb"). Wiring + /// the ignored Gemfile reported success while bundler installed the + /// upstream gem; vendor must refuse before any write instead. + #[tokio::test] + async fn gems_rb_twin_is_refused_before_any_write() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + tokio::fs::write(root.join("gems.rb"), GEMFILE_DIRECT).await.unwrap(); + tokio::fs::write(root.join("gems.locked"), LOCK_DIRECT).await.unwrap(); + + let (code, detail) = + unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + assert_eq!(code, "gemfile_not_loaded"); + assert!(detail.contains("gems.rb"), "{detail}"); + for (file, want) in [ + (GEMFILE, GEMFILE_DIRECT), + (GEMFILE_LOCK, LOCK_DIRECT), + ("gems.rb", GEMFILE_DIRECT), + ("gems.locked", LOCK_DIRECT), + ] { + assert_eq!(tokio::fs::read_to_string(root.join(file)).await.unwrap(), want); + } + assert!(!root.join(".socket/vendor").exists()); + } + + /// #390: `bundle config set --local gemfile Gemfile.next` makes bundler + /// load `Gemfile.next` (+ `Gemfile.next.lock`); wiring `Gemfile` left the + /// loaded manifest unpatched. Refused before any write. + #[tokio::test] + async fn bundle_gemfile_naming_another_manifest_is_refused() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + tokio::fs::write(root.join("Gemfile.next"), GEMFILE_DIRECT).await.unwrap(); + tokio::fs::write(root.join("Gemfile.next.lock"), LOCK_DIRECT).await.unwrap(); + tokio::fs::create_dir_all(root.join(".bundle")).await.unwrap(); + tokio::fs::write( + root.join(".bundle/config"), + "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n", + ) + .await + .unwrap(); + + let (code, detail) = + unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); + assert_eq!(code, "gemfile_not_loaded"); + assert!(detail.contains("Gemfile.next"), "{detail}"); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE)).await.unwrap(), + GEMFILE_DIRECT + ); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + LOCK_DIRECT + ); + assert!(!root.join(".socket/vendor").exists()); + } + + /// `BUNDLE_GEMFILE` naming the project's own Gemfile beside a `gems.rb` + /// makes bundler load the Gemfile, so vendoring wires it as usual. + #[tokio::test] + async fn bundle_gemfile_naming_the_gemfile_overrides_a_gems_rb_twin() { + let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; + tokio::fs::write(root.join("gems.rb"), GEMFILE_DIRECT).await.unwrap(); + tokio::fs::create_dir_all(root.join(".bundle")).await.unwrap(); + tokio::fs::write( + root.join(".bundle/config"), + "---\nBUNDLE_GEMFILE: \"Gemfile\"\n", + ) + .await + .unwrap(); + + let (result, _entry, _w) = + unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); + assert!(result.success, "vendor failed: {:?}", result.error); + assert_eq!( + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + expected_lock_direct() + ); + assert_eq!( + tokio::fs::read_to_string(root.join("gems.rb")).await.unwrap(), + GEMFILE_DIRECT + ); + } + #[tokio::test] async fn test_direct_dep_happy_path() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; From 6aca028dfa49d007f1e189a0a0b0f00b6eda263e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 06:40:06 +0000 Subject: [PATCH 3/8] Redirect only the gem manifest Bundler loads Hosted gem mode picked gems.rb or Gemfile by filename and ignored BUNDLE_GEMFILE. With 'bundle config set --local gemfile Gemfile.next' it rewrote Gemfile, reported success and attested the patch in the in-run VEX, while Bundler kept installing the unpatched gem. The hosted candidate files now hold only the pair Bundler loads. A BUNDLE_GEMFILE naming the project's Gemfile or gems.rb selects that pair; one naming any other file redirects no gem and reports redirect_gem_bundle_gemfile_unsupported. Refs #390 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/formats/gem/manifest.rs | 2 +- crates/socket-patch-core/src/hosted/engine.rs | 184 ++++++++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) diff --git a/crates/socket-patch-core/src/formats/gem/manifest.rs b/crates/socket-patch-core/src/formats/gem/manifest.rs index e9601f446..a48ea2e1a 100644 --- a/crates/socket-patch-core/src/formats/gem/manifest.rs +++ b/crates/socket-patch-core/src/formats/gem/manifest.rs @@ -81,7 +81,7 @@ impl LoadedManifest { match self { LoadedManifest::Unsupported { value, by } => Some(format!( "bundler loads `{value}` ({}), not the project's Gemfile or gems.rb; \ - socket-patch only wires those, so this gem was left untouched (unset \ + socket-patch only wires those, so it left the gem manifests untouched (unset \ BUNDLE_GEMFILE, or point it at the project's Gemfile, and re-run)", by.describe() )), diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 2d9173b9a..5cf80cd9d 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -302,6 +302,10 @@ pub struct CandidateFiles { /// project whose candidates could rewrite (or whose rewrite depends on) /// one is refused, since the rewriters would treat it as absent. pub unreadable_reads: Vec, + /// Set when bundler is configured (`BUNDLE_GEMFILE`) to load a manifest + /// the gem rewriter cannot edit: every gem manifest and lock was left + /// out of `files`, and the rewrite reports this instead of a redirect. + pub gem_manifest_unsupported: Option, } impl CandidateFiles { @@ -440,6 +444,9 @@ pub async fn read_candidate_files( } } } + if candidates.iter().any(|c| c.dep.ecosystem == "gem") { + keep_bundler_loaded_gem_files(view, &mut out).await; + } out.symlinked_reads.sort(); out.symlinked_reads.dedup(); out.unreadable_reads.sort(); @@ -447,6 +454,57 @@ pub async fn read_candidate_files( out } +/// The Bundler manifest/lock spellings among the candidate files. +const GEM_MANIFEST_FILES: [&str; 4] = ["Gemfile", "Gemfile.lock", "gems.rb", "gems.locked"]; + +/// Leave only the gem manifest pair bundler loads in the candidate set +/// (see [`crate::formats::gem::manifest`]). The gem rewriter picks between +/// the two default spellings by filename alone; this narrows what it sees +/// to bundler's own choice, so it can never wire a manifest bundler +/// ignores: +/// +/// - no `BUNDLE_GEMFILE`: unchanged (the rewriter's `gems.rb`-first choice +/// and its divergence guard are bundler's default discovery); +/// - `BUNDLE_GEMFILE` naming the root `Gemfile` / `gems.rb`: the other +/// spelling is dropped; +/// - `BUNDLE_GEMFILE` naming anything else: every spelling is dropped and +/// [`CandidateFiles::gem_manifest_unsupported`] says why. +/// +/// A memory view has no environment: only its own app config is read. +async fn keep_bundler_loaded_gem_files(view: &ProjectView<'_>, out: &mut CandidateFiles) { + use crate::formats::gem::manifest::{self, LoadedManifest}; + let loaded = match view { + ProjectView::Disk(root) + | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { root, .. }) => { + manifest::loaded_manifest(root).await + } + ProjectView::Memory(_) => { + let config = view.read_text(".bundle/config").await.ok(); + let value = config.as_deref().and_then(manifest::config_gemfile); + let root = std::path::Path::new("/"); + manifest::classify(root, root, None, value.as_deref()) + } + }; + let keep: &[&str] = match &loaded { + LoadedManifest::Default => return, + LoadedManifest::Configured { .. } => { + let (gemfile, lock) = loaded + .pair(out.files.contains_key("gems.rb")) + .expect("a configured default spelling has a pair"); + &[gemfile, lock] + } + LoadedManifest::Unsupported { .. } => &[], + }; + let dropped = |rel: &str| GEM_MANIFEST_FILES.contains(&rel) && !keep.contains(&rel); + out.files.retain(|rel, _| !dropped(rel)); + out.symlinked_reads.retain(|rel| !dropped(rel)); + out.unreadable_reads.retain(|rel| !dropped(rel)); + out.gem_manifest_unsupported = loaded.unsupported_detail().map(|detail| RewriteWarning { + code: "redirect_gem_bundle_gemfile_unsupported".into(), + detail, + }); +} + /// The pypi wheels whose metadata a native lock rewrite needs, in /// candidate order: `(override, sha256)` of every `.whl` artifact some /// `uv.lock` / PEP 723 script lock would rewrite. Each native lock is @@ -709,6 +767,7 @@ pub async fn rewrite( rush_lock_keys, symlinked_reads, unreadable_reads, + gem_manifest_unsupported, } = read; // The rewriters' override slice — materialized ONCE, after the last // candidate filter, so it can never disagree with `candidates`. @@ -770,6 +829,13 @@ pub async fn rewrite( ); (files, rewrite) }; + // The gem files were withheld on purpose: say why, not "no Gemfile". + if let Some(warning) = gem_manifest_unsupported { + rewrite + .warnings + .retain(|w| w.code != "redirect_gem_no_gemfile"); + rewrite.warnings.push(warning); + } if let Some(content) = binary_content { rewrite .warnings @@ -1556,6 +1622,124 @@ mod tests { assert!(read.unreadable_reads.is_empty()); } + fn gem_candidate() -> Candidate { + use crate::patch::redirect::{Integrity, RegistryOverride, RegistryOverrideIdentifiers}; + Candidate { + purl: "pkg:gem/rails@7.0.0".into(), + dep: DepOverride { + ecosystem: "gem".into(), + name: "rails".into(), + namespace: None, + version: "7.0.0".into(), + token: "tok".into(), + patch_uuid: "uuid".into(), + artifact_url: "https://patch.test/rails-7.0.0.gem".into(), + registry_override: Some(RegistryOverride { + kind: "rubygems-compact-index".into(), + index_url: "https://patch.test/gem/tok/uuid/".into(), + identifiers: RegistryOverrideIdentifiers { + name: "rails".into(), + version: "7.0.0".into(), + gem_checksum_sha256: Some("f".repeat(64)), + ..Default::default() + }, + }), + integrity: Integrity::default(), + }, + } + } + + const GEMFILE: &str = "source \"https://rubygems.org\"\n\ngem \"rails\", \"7.0.0\"\n"; + const GEM_LOCK: &str = "GEM\n remote: https://rubygems.org/\n specs:\n rails (7.0.0)\n\n\ + PLATFORMS\n ruby\n\nDEPENDENCIES\n rails (= 7.0.0)\n\nBUNDLED WITH\n 2.5.22\n"; + + async fn gem_rewrite(p: &MemoryProject) -> (CandidateFiles, Rewritten) { + let outer = OuterAllowRemote::default; + let options = RewriteOptions { + dry_run: false, + targets_pipenv_lock: false, + pipenv_major: None, + pipenv_unknown_detail: String::new(), + trust_lockfile_config: true, + npm_allow_remote_config: true, + npm_outer: &outer, + blocking: false, + }; + let candidates = vec![gem_candidate()]; + let view = ProjectView::Memory(p); + let read = read_candidate_files(&view, &BTreeSet::new(), &candidates).await; + let done = rewrite( + &view, + read.clone(), + &candidates, + BTreeMap::new(), + &BTreeSet::new(), + &[], + options, + ) + .await; + (read, done) + } + + /// #390: `bundle config set --local gemfile Gemfile.next` makes bundler + /// load `Gemfile.next`; the hosted redirect used to rewrite `Gemfile` + /// (which bundler ignores) and attest the patch. Now no gem file is a + /// candidate and the run says why. + #[tokio::test] + async fn bundle_gemfile_naming_another_manifest_redirects_nothing() { + let mut p = MemoryProject::new(); + for name in ["Gemfile", "Gemfile.next"] { + p.insert_text(name, GEMFILE); + } + for name in ["Gemfile.lock", "Gemfile.next.lock"] { + p.insert_text(name, GEM_LOCK); + } + p.insert_text(".bundle/config", "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n"); + let (read, done) = gem_rewrite(&p).await; + assert!(!read.files.contains_key("Gemfile")); + assert!(!read.files.contains_key("Gemfile.lock")); + assert!( + done.rewrite.files.keys().all(|k| !k.starts_with("Gemfile")), + "{:?}", + done.rewrite.files.keys() + ); + let codes: Vec<&str> = done.rewrite.warnings.iter().map(|w| w.code.as_str()).collect(); + assert!(codes.contains(&"redirect_gem_bundle_gemfile_unsupported"), "{codes:?}"); + assert!(!codes.contains(&"redirect_gem_no_gemfile"), "{codes:?}"); + } + + /// `BUNDLE_GEMFILE: Gemfile` beside a `gems.rb`: bundler loads the + /// Gemfile pair, so that is the pair the redirect edits (the rewriter's + /// own filename rule would have picked gems.rb). + #[tokio::test] + async fn bundle_gemfile_naming_the_gemfile_redirects_it_over_gems_rb() { + let mut p = MemoryProject::new(); + p.insert_text("Gemfile", GEMFILE); + p.insert_text("Gemfile.lock", GEM_LOCK); + p.insert_text("gems.rb", GEMFILE); + p.insert_text("gems.locked", GEM_LOCK); + p.insert_text(".bundle/config", "---\nBUNDLE_GEMFILE: \"Gemfile\"\n"); + let (_read, done) = gem_rewrite(&p).await; + assert!(done.rewrite.files.contains_key("Gemfile"), "{:?}", done.rewrite.files.keys()); + assert!(!done.rewrite.files.contains_key("gems.rb")); + assert!(!done.rewrite.files.contains_key("gems.locked")); + } + + /// Without `BUNDLE_GEMFILE` nothing changes: `gems.rb` is still the + /// spelling bundler (and the rewriter) picks. + #[tokio::test] + async fn default_discovery_still_prefers_gems_rb() { + let mut p = MemoryProject::new(); + p.insert_text("Gemfile", GEMFILE); + p.insert_text("Gemfile.lock", GEM_LOCK); + p.insert_text("gems.rb", GEMFILE); + p.insert_text("gems.locked", GEM_LOCK); + let (read, done) = gem_rewrite(&p).await; + assert!(read.files.contains_key("Gemfile")); + assert!(done.rewrite.files.contains_key("gems.rb"), "{:?}", done.rewrite.files.keys()); + assert!(!done.rewrite.files.contains_key("Gemfile")); + } + #[test] fn file_ecosystems_cover_the_rewrite_targets() { assert_eq!(file_ecosystem("package-lock.json"), Some("npm")); From 5499ee2db7cbea3321e28cdb58c1e28389f8390b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 06:46:17 +0000 Subject: [PATCH 4/8] Test and document Bundler manifest selection Real-Bundler e2e capstones for both issues. A hosted scan of a dual-boot project (BUNDLE_GEMFILE: Gemfile.next in .bundle/config) redirects and attests nothing. Vendor refuses a gems.rb twin and the dual-boot layout, leaving every manifest and lock untouched. Docs, CLI contract and CHANGELOG describe the new refusals. Refs #341, #390 Assisted-by: Claude Code:claude-opus-5-5 --- CHANGELOG.md | 4 + crates/socket-patch-cli/CLI_CONTRACT.md | 2 +- .../tests/e2e_redirect_gem_build.rs | 102 +++++++++++- .../tests/e2e_vendor_gem_build.rs | 157 ++++++++++++++++++ .../src/formats/gem/manifest.rs | 19 ++- crates/socket-patch-core/src/hosted/engine.rs | 24 ++- crates/socket-patch-core/src/vendor/gem.rs | 45 +++-- docs/ecosystems.md | 2 +- 8 files changed, 330 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a92c2aea..684844341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,10 @@ limits, and required install commands. ### Fixed +- Gem hosted and vendored modes wire only the manifest Bundler loads. A `gems.rb` + twin or a `BUNDLE_GEMFILE` setting (environment or `.bundle/config`) no longer + leads to an edit of an ignored `Gemfile` that reports success and attests an + unpatched gem; unsupported layouts are refused before any write (#341, #390). - Patch application, reversal, and cleanup handle missing files, release variants, corrupt state, newer ledger formats, and unsafe manifest paths without silently dropping protection. File ownership restoration failures produce warnings. diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index bcbf0fa68..5e5f899b5 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -159,7 +159,7 @@ For a **9.0 root lock**, the CLI ensures `pnpm-workspace.yaml` carries `trustLoc **Vendored entries and the rest of the CLI.** Because nothing is in the manifest, vendored patches are invisible to `apply` (nothing to apply in place) but fully visible to `list` (listed from the ledger, labeled `Mode: vendored (recorded in .socket/vendor/state.json)` in human mode, exit 0 on a vendored-only project), `vex` (attested from the embedded records while a lockfile still wires the artifact — see "Manifest-less VEX"), `repair` (health-checked and rebuilt from the ledger), and `scan --prune` (lockfile-driven reconcile). They are exempt from standalone `vendor`'s manifest reconcile (`reconcile_dropped` never touches `detached` entries) and exit via `remove ` (which reverts them), `vendor --revert`, or `rollback`, whose vendored leg reverts every in-scope ledger entry (unscoped and identifier-scoped runs; path-scoped runs reach them only when an installed copy matches). -`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). +`scan --mode hosted` swaps the in-place apply for the registry-redirect pipeline: discover → resolve hosted-patch references (grant token + integrity + per-dep registry override) → rewrite ONLY the patched dependencies' lockfile / registry-config entries to point at the hosted packages. A dep counts as **redirected** only when its hosted-artifact URL (or per-dep registry index URL) actually landed in a project file — a granted reference whose rewriter found nothing to edit is neither counted nor attested. **No ledger (v5.0)**: hosted mode writes ONLY the lockfile / registry-config edits — `.socket/vendor/redirect-state.json` is never written (on success or failure), and a pre-v5 one on disk is ignored (never read for planning, never quarantined, left byte-identical). The lockfiles are the only record of a hosted patch: `list`, `vex`, `rollback`, `remove`, `vendor` and `repair` all discover the hosted pins from them (a hosted URL counts only on `https://patch.socket.dev` or the `--patch-server-url` / `SOCKET_PATCH_SERVER_URL` origin), and commit-ready output is just the lockfile / config changes. Cargo and golang are confirmed only by their rewriter's own report (`confirmed_cargo_uuids` / `confirmed_golang_uuids`): a golang dep counts only when its go.mod `replace M V => patch.socket.dev/gopatch/ ` and both go.sum lines are in place, never because the patch-server origin or leftover go.sum lines appear somewhere. A golang module that go.mod does not require and go.sum does not list at the patched version is outside the build graph and is refused with `redirect_golang_not_in_module_graph` (nothing written). Only the exact module `patch.socket.dev/gopatch/` is socket-owned; any other module path is refused with `redirect_golang_untrusted_module_path`. A vendored golang module is taken over like cargo and the npm family: its vendor wiring, committed copy and ledger entry are reverted first (`redirect_takeover_reverted_vendored`). Re-runs over already-rewritten output plan from the current lock text and are idempotent (exit 0, lock unchanged). **Lock (v5.0)**: the hosted engine acquires `<.socket>/apply.lock` around its first wet write (the takeover pre-reverts) — not on `--dry-run`, and not when the run would write nothing (zero redirects, all skipped) — so previews and no-op runs never create `.socket/`; contention is `lock_held` and a lock-file I/O fault (a read-only project root, a file squatting on `.socket/`) is `lock_io` — both exit 1, refused BEFORE any project file is written, and rendered like every other lock holder: human `Error (): ` on stderr (+ the `--lock-timeout` hint for a live holder); JSON keeps the hosted shape — top-level `status: "error"`, `errorCode: "lock_held" | "lock_io"`, a string `error`, and `redirect: {mode: "hosted"}` retained (NOT the vendored `error: {code, message}` object). **Takeover symlink pre-check (v5.0)**: a vendored→hosted takeover whose recorded wiring file is a symlink is refused up front with `redirect_symlinked_file_unsupported` — wet and `--dry-run` alike, before any revert — so "nothing was written" holds. **Human mode (v5.0)**: hosted `scan` prints the results table and update detection like the other modes, then rewrites without a prompt (scan never prompts); `--dry-run` previews through the engine, and a detail fetch that leaves nothing to redirect enters the engine as a no-op (`Redirected 0 packages; rewrote 0 files.`, no lock, no `.socket/`). The detail fetch prints the same progress counter and per-package `Warning: could not fetch details for …` lines as the agent arm. An EMPTY hosted discovery prints `No patches available for installed packages.` and exits 0 without entering the engine; a discovery whose every offer is paid-tier for an org without paid access prints the table's paid nudge, then `No downloadable patches (paid subscription required).`, and exits 0 without entering the engine (parity with the agent/vendored arms). JSON output gains a `redirect` sub-object: `{ mode: "hosted", redirected, rewrittenFiles, skipped, warnings, dryRun }` (`mode` is additive so consumers can dispatch without inferring it). Rewriter warnings carry stable `redirect_*` codes (e.g. `redirect_npm_no_lockfile`, `redirect_gradle_manual_snippet`, `redirect_golang_unsupported`); new codes are additive (MINOR). v5.0 additive codes: `redirect_composer_no_lockfile` / `redirect_gem_no_gemfile` (composer / gem: neither manifest nor lock present — once per run, after the intake gates), `redirect_gem_bundle_gemfile_unsupported` (gem: `BUNDLE_GEMFILE` — the environment variable, or `BUNDLE_GEMFILE:` in the bundler app config — names a manifest other than the project's `Gemfile` / `gems.rb`, so no gem is redirected or attested; a value naming one of those two selects that pair even when the other spelling is present), `redirect_maven_no_pom` (no `pom.xml` and no Gradle build), `redirect_nuget_lock_unparseable` (a present-but-corrupt `packages.lock.json` — warned once, nothing mutated; an absent lock still proceeds), `redirect_cargo_lock_pkg_ambiguous` (several same-name+version `[[package]]` blocks and none carries the index `source` — transactional skip). Also v5.0: a registry override of the wrong kind (or none at all) warns the arm's missing-override code for nuget/gem/golang. Refusals stay fail-closed with a diagnosis that names the actual cause: a yarn-berry lock entry resolving through a non-`npm:` protocol keeps `redirect_yarn_berry_unsupported_protocol` with the entry's ACTUAL protocol in the detail — except socket-patch's OWN vendored wiring (a `file:` range into `.socket/vendor/`), which gets the distinct `redirect_yarn_berry_vendored_entry` code whose detail names the retirement path (`remove ` per package, or `vendor --revert` which unwinds every vendored package, then re-run `scan --mode hosted`). Both leave the entry byte-identical; neither changes exit code or status. **yarn berry line endings (v5.0)**: yarn writes a NEW `yarn.lock` with the OS line ending (`os.EOL` — CRLF on Windows) and keeps an existing lock's majority ending on every later write, and a `core.autocrlf` checkout turns an LF lock CRLF on any OS — so a uniformly CRLF lock is rewritten in its own ending: every untouched byte (a leading BOM included) round-trips (and `rollback`'s upstream restore keeps the lock's own ending). A lock that MIXES CRLF and LF (or holds a bare CR) has no single ending to keep — yarn's own `--immutable` check rejects it too (YN0028) — so it is refused untouched with `redirect_yarn_berry_mixed_line_endings` (the detail names `yarn install`, which normalizes it). This replaces v4's `redirect_yarn_berry_crlf_unsupported`, which refused every CRLF lock and is no longer emitted. A vendored→hosted takeover runs these berry gates (mixed line endings, unsupported `cacheKey`, a non-zero `.yarnrc.yml` `compressionLevel`) BEFORE reverting a vendored berry purl — wet and `--dry-run` alike — so a refused purl keeps its vendored wiring, ledger entry and artifact byte-identical and is skipped with the gate's code (never announced as `redirect_takeover_reverted_vendored` and then left unpatched in both modes). The rewriter reads a fixed set of candidate files from the project root: the npm-family locks (`package-lock.json`, `npm-shrinkwrap.json`, `pnpm-lock.yaml`, `shrinkwrap.yaml`, `yarn.lock`, plus `.yarnrc.yml` for the berry cache-config gate, `bun.lock` / `bun.lockb`, and `vlt-lock.json` with `vlt.json` and `node_modules/.vlt-lock.json` read only), `requirements.txt` / `uv.lock` / `Pipfile.lock` (pipfile-spec 6; see the Pipenv section below) / `poetry.lock` (every Poetry lock generation from 1.0 on — the 0.12 `[metadata.hashes]` layout is refused because that installer ignores URL sources; a Poetry < 1.4 writer additionally gets `redirect_poetry_stale_install_risk`, see `docs/testing/poetry-compatibility.md`) / `pdm.lock` (PDM lock formats `2` and `4.3`–`4.5.1`; the identity-losing `3.1` / `4.0`–`4.2` formats and unknown future formats are refused with `redirect_pdm_refused`, and a lock-format-`2` writer additionally gets `redirect_pdm_legacy_sync_required`, see `docs/testing/pdm-compatibility.md`; when `uv.lock` or `poetry.lock` sits beside it they drive and `pdm.lock` is left alone), `Cargo.toml` / `Cargo.lock` / `.cargo/config.toml` (plus the legacy extensionless `.cargo/config` — cargo reads that spelling in preference when both exist, so the managed `[registries.…]` block is written into whichever one is present; **cargo also reads every workspace-member manifest** — the `[workspace] members` globs minus `exclude` — and every in-root path-dependency manifest, recursively, reached without crossing a symbolic link and never under `.socket/`, and pins the crate in each one that declares it, so those `/Cargo.toml` files can appear in `rewrittenFiles`. A crate is redirected only when every declaration pins and every other `Cargo.lock` package depending on it is a planned member: one a registry or git crate — or a path package outside the root or behind a link — also depends on is refused `redirect_cargo_transitive_dependents` (a pin reaches only the declarations it sits on), a crate no manifest declares keeps `redirect_cargo_toml_dep_not_found` with a transitive-only detail naming `--mode vendored`, a crate every declaration of which requires another version (no requirement accepts the patched version) is refused `redirect_cargo_toml_dep_unrewritable`, and so is a requirement that also matches another locked version of the crate — each a transactional skip, never recorded or attested. With NO `Cargo.lock` there is no resolved graph to ask, so the dependents question is answered from the manifests instead: a crate declared beside any other dependency — anything but a path dependency on a manifest this run also pins, or a `workspace = true` inheritor of a table it scans — or beside a workspace member this run did not read (a `members` glob, or a member outside the project or behind a symbolic link, which member discovery drops) is refused `redirect_cargo_lockless_dependents`, whose detail names the remedies (commit a lockfile, or `--mode vendored`); a project whose only dependency is the patched crate has nothing that could pull it in and still redirects. All-CRLF manifests, locks and configs are rewritten with CRLF kept (mixed endings keep refusing where the grammar does not match), and `remove` / rollback match the recorded fragments across a later CRLF↔LF checkout conversion), `composer.lock`, `nuget.config` / `packages.lock.json`, `Gemfile` / `Gemfile.lock`, `pom.xml` (+ `.mvn/maven.config` / `.mvn/checksums/checksums.sha256` for maven Trusted Checksums merge, and the Gradle build scripts read only to trigger the manual-snippet warning). **npm-family flavor coverage**: package-lock / npm-shrinkwrap, pnpm (root OR any nested `*/pnpm-lock.yaml`), yarn classic, **yarn berry** (`yarn.lock` entry only — `resolution: ::__archiveUrl=` + `yarnBerry10c0` checksum; cacheKey `10c0` and `.yarnrc.yml compressionLevel 0` gated by `redirect_yarn_berry_cache_unsupported`), and **bun** (text `bun.lock` lockfileVersion 0, 1 or 2 — 0 is the `--save-text-lockfile` opt-in lock of Bun 1.1.39–1.1.45, 1 the 1.2–1.3 default, 2 the 1.4+ default; all three emit one `packages` grammar, so the registry 4-tuple → URL 3-tuple rewrite is version-independent and the lock's own version line is kept. Any other or missing version, or a `packages` section outside bun's single-line grammar, is refused `redirect_bun_lock_unsupported` — the detail is the shared version gate's text (a newer version: update socket-patch, re-locking would reproduce it; no integer: re-lock with Bun ≥ 1.2), identical to the vendored refusal. A version-0 lock holding `workspace:` packages is refused `redirect_bun_workspace_unsupported` (its 2-tuple workspace grammar cannot keep the hosted tuple through a frozen install); the remedy is to delete `bun.lock` and re-run `bun install` with Bun ≥ 1.2, which writes lockfileVersion 1 (accepted). A plain in-place `bun install` bumps the version only when a workspace depends on another workspace (e.g. root → member — the shape the matrix measured); otherwise Bun 1.2.0 keeps version 0 and Bun 1.2.23+ fail to resolve, so the in-place bump is not the documented remedy. Bun lock version, grammar and workspace compatibility are checked before a vendored takeover, including during dry-run: these refusals preserve the existing lock, artifact and vendor ledger. Version-1 and version-2 workspace locks are rewritten, nested versions included. A granted dep with no rewritable entry warns `redirect_bun_entry_not_found`, a grant without a sha512 `redirect_bun_missing_sha512`; a CRLF lock keeps `\r\n` on the rewritten line, and a hosted URL left by an earlier grant of the same `name@version` is re-pinned in place. **Digest-less re-saves (Bun 1.1.39–1.3.9)**: every text-lock Bun below 1.3.10 re-saves a URL tuple WITHOUT its `sha512` whenever the lock is re-saved for another reason (`bun add`, `bun install` after a package.json or workspace change), leaving the 2-tuple `["name@", {meta}]` — the spec Bun installs from is intact. The CLI treats that spelling as its own wiring: a repeat hosted run counts the dep as redirected (no `redirect_bun_entry_not_found`) and HEALS the line back to the 3-tuple with the current `sha512`, recording the heal as a further `redirect_bun_lock_package` edit whose `original` is the 2-tuple (a stale URL is re-pinned from either spelling); `rollback`, scoped `rollback ` / `remove ` and the vendored takeover accept the digest-less spelling of a recorded `new` line (same key, spec and meta, only the trailing `"sha512-…"` missing) and restore the recorded original over it, so the chain always unwinds to the pristine registry line. Anything else — another uuid/token, another version, a re-laid meta object — is still drift. **Native `bun.lockb`**: when no text `bun.lock` exists, binary format versions 1, 2 and 3 are read and rewritten directly. Socket Patch does not invoke Bun or convert the project to a text lockfile. Exact matching package records are rewritten to hosted tarballs with the granted integrity, preserving dependency resolution IDs, workspace/dependency topology and unrelated package metadata; binary pointers and the package metadata hash are updated. Per-package `redirect_bun_lockb_package` snapshots support scoped rollback, repeat runs, superseding grants and hosted ↔ vendored takeover. A regular binary lock is discoverable even with no Bun runtime or `node_modules`; a dry run previews the same binary edits without writing them. A malformed, unreadable, unsupported or unverified binary structure is `redirect_bun_lockb_invalid` (exit 0, `redirected: 0`), and it refuses the npm rewrite before any takeover or sibling npm-family lock mutation. A symlinked binary write target is `redirect_symlinked_file_unsupported` (exit 1, including dry-run). `bun.lock` wins when both spellings exist. Binary-only projects do not receive `redirect_npm_no_lockfile`. Measured boundaries and the real-Bun matrix: `docs/testing/bun-compatibility.md`), and **vlt** (`vlt-lock.json` without `lockfileVersion`, `0` or `1`; see the vlt hosted-mode contract below). **Rush monorepos**: when `rush.json` is present the rewriter also reads `common/config/rush/pnpm-lock.yaml` and each `common/config/subspaces//pnpm-lock.yaml` (sorted for determinism) under their repo-relative keys and repoints them in place; editing them emits `redirect_rush_repo_state_stale` when `common/config/rush/repo-state.json` exists (the `pnpmShrinkwrapHash` desync is refreshed by `rush update`, which the redirect survives). **maven** is fail-closed via version suffixing: a `mavenSuffixedVersion` + `mavenPomSha256` override pins the Socket-only `-socket.` by rewriting the literal `` (`redirect_maven_dep_version`) or adding a `` entry (`redirect_maven_dep_management_added`), plus optional Trusted Checksums (`redirect_maven_trusted_checksums`, conflicts as `redirect_maven_trusted_checksums_conflict`); a `${property}` version is refused (`redirect_maven_dep_unpinned`), a non-matching literal skipped (`redirect_maven_dep_version_mismatch`), and an override without a suffixed version falls back to same-GAV repository injection (`redirect_maven_same_gav_fallback`, NOT fail-closed). diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs index 28191bbf0..d7e31dfe1 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -400,6 +400,11 @@ enum Driver { /// selector (v4.0). No `--vex` (get has none); the uuid path needs only /// the view + reference mocks and is exempt from installed narrowing. GetUuid, + /// [`Driver::ScanVex`] on a dual-boot project whose `.bundle/config` + /// sets `BUNDLE_GEMFILE: "Gemfile.next"` (#390): bundler loads + /// `Gemfile.next`, so the run must redirect nothing and attest nothing. + /// The fixture asserts that contract itself and yields `None`. + ScanVexDualBoot, } impl Driver { @@ -407,6 +412,7 @@ impl Driver { match self { Driver::ScanVex => "scan --mode hosted", Driver::GetUuid => "get --mode hosted", + Driver::ScanVexDualBoot => "scan --mode hosted (BUNDLE_GEMFILE=Gemfile.next)", } } } @@ -754,8 +760,22 @@ async fn redirect_scanned_project( // --vex (get has none), get's envelope with the nested `redirect`. let api = server.uri(); let proj_str = proj.to_str().expect("utf8 tmp path"); + if driver == Driver::ScanVexDualBoot { + // The next-Rails dual boot: a `Gemfile.next` pair that bundler loads + // through the committed `.bundle/config`. + std::fs::copy(proj.join(gemfile_name), proj.join("Gemfile.next")).unwrap(); + std::fs::copy(proj.join(lock_name), proj.join("Gemfile.next.lock")).unwrap(); + let args = bundler.config_local_args("gemfile", "Gemfile.next"); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let cfg = bundle(&proj, &args); + assert!( + cfg.status.success(), + "bundle config set --local gemfile failed:\n{}", + String::from_utf8_lossy(&cfg.stderr) + ); + } let argv: Vec<&str> = match driver { - Driver::ScanVex => vec![ + Driver::ScanVex | Driver::ScanVexDualBoot => vec![ "scan", "--mode", "hosted", @@ -792,6 +812,19 @@ async fn redirect_scanned_project( ], }; let (code, stdout, stderr) = run_socket(&proj, &argv); + if driver == Driver::ScanVexDualBoot { + let env: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("not JSON: {e}\nstdout:\n{stdout}\nstderr:\n{stderr}")); + // `--vex` with nothing to attest is an error: the run must not + // look like a successful, attested patch. + assert_ne!(code, 0, "nothing was patched or attested: {env}"); + assert_eq!( + env["error"]["code"], "manifest_not_found", + "envelope: {env}" + ); + assert_dual_boot_redirects_nothing(&env, &proj, &pristine_gemfile, &pristine_lock); + return None; + } assert_eq!( code, 0, @@ -865,6 +898,7 @@ async fn redirect_scanned_project( "in-run hosted VEX is attested from this run's fetched record, not hash-verified: {env}" ); } + Driver::ScanVexDualBoot => unreachable!("asserted and returned above"), Driver::GetUuid => { // get's hosted envelope (CLI_CONTRACT.md "get --mode and // installed narrowing"): `found` counts the resolved patch; @@ -918,6 +952,52 @@ async fn redirect_scanned_project( }) } +/// #390's contract on a `BUNDLE_GEMFILE: Gemfile.next` project: the hosted +/// scan names the setting, rewrites neither the `Gemfile` pair (which +/// bundler ignores) nor `Gemfile.next`, and its in-run VEX attests nothing. +/// Then the real bundler, loading `Gemfile.next`, resolves the upstream gem +/// (nothing pretends otherwise). +fn assert_dual_boot_redirects_nothing( + env: &serde_json::Value, + proj: &Path, + pristine_gemfile: &[u8], + pristine_lock: &[u8], +) { + let warning_codes: Vec<&str> = env["redirect"]["warnings"] + .as_array() + .map(|a| a.iter().filter_map(|w| w["code"].as_str()).collect()) + .unwrap_or_default(); + assert!( + warning_codes.contains(&"redirect_gem_bundle_gemfile_unsupported"), + "the BUNDLE_GEMFILE refusal must be reported: {env}" + ); + assert!( + !warning_codes.contains(&"redirect_gem_no_gemfile"), + "the refusal names its real cause, not a missing Gemfile: {env}" + ); + assert_eq!( + env["redirect"]["redirected"], 0, + "nothing redirected: {env}" + ); + assert!( + env["vex"]["statements"].as_u64().unwrap_or(0) == 0, + "no in-run attestation for a gem bundler installs unpatched: {env}" + ); + for (file, want) in [ + ("Gemfile", pristine_gemfile), + ("Gemfile.lock", pristine_lock), + ("Gemfile.next", pristine_gemfile), + ("Gemfile.next.lock", pristine_lock), + ] { + assert_eq!( + std::fs::read(proj.join(file)).unwrap(), + want, + "{file} must be byte-untouched" + ); + } + assert_no_redirect_ledger(proj); +} + /// v5 hosted mode never writes `.socket/vendor/redirect-state.json`. fn assert_no_redirect_ledger(proj: &Path) { assert!( @@ -1388,6 +1468,26 @@ async fn gem_hosted_gems_rb_spelling_redirects_and_installs() { manifestless_vex_matrix(&fx, &fresh).await; } +/// #390: bundler's `BUNDLE_GEMFILE` (here a committed `.bundle/config` +/// naming `Gemfile.next`, the dual-boot layout) picks the manifest it +/// loads. The hosted scan used to rewrite the ignored `Gemfile`, report +/// success and attest the patch; it must redirect and attest nothing. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17; CHECKSUMS arm >= 2.6); \ + the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"] +async fn gem_hosted_bundle_gemfile_dual_boot_redirects_nothing() { + let fx = redirect_scanned_project( + "dual-boot", + Spelling::Gemfile, + false, + true, + None, + Driver::ScanVexDualBoot, + ) + .await; + assert!(fx.is_none(), "the dual-boot driver asserts in place"); +} + /// The compact-index DEPENDENCY contract, pinned from the red side: a patch /// registry whose `/info` omits the gem's runtime deps (production's /// HISTORICAL behavior until the 2026-08-18 republish fixed the served index) diff --git a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs index cf8e660d5..1dd1d75bc 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs @@ -1398,3 +1398,160 @@ async fn gem_get_uuid_vendored_fresh_checkout_bundle_install() { }); }); } + +/// A real `rack ~> 3.1` project installed into `vendor/bundle`, with a +/// marker patch for the installed rack staged under `.socket/`. `None` = +/// skip (message already printed). +fn staged_rack_project( + tag: &str, +) -> Option<(tempfile::TempDir, PathBuf, bundler_e2e::Bundler, String)> { + let bundler = gate(tag)?; + let tmp = tempfile::tempdir().unwrap(); + let proj = tmp.path().join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("Gemfile"), + "source \"https://rubygems.org\"\n\ngem \"rack\", \"~> 3.1\"\n", + ) + .unwrap(); + let config = bundle( + &proj, + &argv(&bundler.config_local_args("path", "vendor/bundle")), + false, + ); + let install = config + .status + .success() + .then(|| bundle(&proj, &["install"], false)); + if !install.as_ref().is_some_and(|i| i.status.success()) { + println!("SKIP e2e_vendor_gem_build ({tag}): fixture `bundle install` failed"); + return None; + } + let lock = std::fs::read_to_string(proj.join("Gemfile.lock")).unwrap(); + let version = locked_gem_version(&lock, DEP).expect("resolved rack version"); + let mut ruby = Command::new("ruby"); + ruby.args(["-e", "puts Gem.ruby_api_version"]); + cache_env::isolate(&mut ruby); + let api = ruby.output().expect("failed to run ruby"); + let api = String::from_utf8_lossy(&api.stdout).trim().to_string(); + let installed_rb = proj + .join("vendor/bundle/ruby") + .join(&api) + .join("gems") + .join(format!("{DEP}-{version}")) + .join("lib/rack.rb"); + let orig = std::fs::read(&installed_rb).expect("installed lib/rack.rb"); + let patched: Vec = [orig.as_slice(), b"\n# SOCKET-PATCH-VENDOR-E2E-MARKER\n"].concat(); + let purl = format!("pkg:gem/{DEP}@{version}"); + stage_patch_with_vuln(&proj, &purl, "lib/rack.rb", &orig, &patched); + Some((tmp, proj, bundler, purl)) +} + +/// Run `vendor` on a project where bundler loads a manifest other than the +/// Gemfile, and assert the refusal: a `gemfile_not_loaded` failure for the +/// gem, and every manifest and lock byte-untouched. +fn assert_vendor_refuses_unloaded_gemfile(proj: &Path, purl: &str, loaded: &str, files: &[&str]) { + // The premise: real bundler loads `loaded`, not the Gemfile. + let probe = bundle( + proj, + &["exec", "ruby", "-e", "puts Bundler.default_gemfile"], + false, + ); + assert!( + String::from_utf8_lossy(&probe.stdout) + .trim() + .ends_with(&format!("/{loaded}")), + "bundler must load {loaded} (test premise):\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&probe.stdout), + String::from_utf8_lossy(&probe.stderr) + ); + let before: Vec> = files + .iter() + .map(|f| std::fs::read(proj.join(f)).unwrap()) + .collect(); + let (code, stdout, stderr) = run_socket( + proj, + &[ + "vendor", + "--json", + "--offline", + "--cwd", + proj.to_str().unwrap(), + ], + ); + assert_ne!( + code, 0, + "vendor must not succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + let env = parse_envelope(&stdout); + assert_eq!(env["summary"]["applied"], 0, "nothing vendored: {env}"); + let event = env["events"] + .as_array() + .unwrap() + .iter() + .find(|e| e["purl"] == purl) + .unwrap_or_else(|| panic!("an event for {purl}: {env}")); + assert_eq!(event["errorCode"], "gemfile_not_loaded", "event: {event}"); + for (file, before) in files.iter().zip(before) { + assert_eq!( + std::fs::read(proj.join(file)).unwrap(), + before, + "{file} must be byte-untouched" + ); + } + assert!( + !proj.join(".socket/vendor/gem").exists(), + "no vendored copy is written" + ); +} + +/// #341: a `gems.rb` / `gems.locked` twin beside the Gemfile pair is what +/// bundler loads. Vendor used to wire the ignored Gemfile, exit 0, and +/// leave bundler installing upstream rack; it must refuse before any write. +#[test] +#[ignore = "host capstone: shells out to a real bundler >= 1.17; the unpinned `test` job \ + skips it, the e2e job runs it with a pinned toolchain via --ignored"] +fn gem_vendor_refuses_a_gems_rb_twin() { + let Some((_tmp, proj, _bundler, purl)) = staged_rack_project("gems.rb twin") else { + return; + }; + std::fs::copy(proj.join("Gemfile"), proj.join("gems.rb")).unwrap(); + std::fs::copy(proj.join("Gemfile.lock"), proj.join("gems.locked")).unwrap(); + assert_vendor_refuses_unloaded_gemfile( + &proj, + &purl, + "gems.rb", + &["Gemfile", "Gemfile.lock", "gems.rb", "gems.locked"], + ); +} + +/// #390: `bundle config set --local gemfile Gemfile.next` (the dual-boot +/// layout) makes bundler load `Gemfile.next`. Vendor used to wire the +/// ignored Gemfile; it must refuse before any write. +#[test] +#[ignore = "host capstone: shells out to a real bundler >= 1.17; the unpinned `test` job \ + skips it, the e2e job runs it with a pinned toolchain via --ignored"] +fn gem_vendor_refuses_a_bundle_gemfile_dual_boot() { + let Some((_tmp, proj, bundler, purl)) = staged_rack_project("dual boot") else { + return; + }; + std::fs::copy(proj.join("Gemfile"), proj.join("Gemfile.next")).unwrap(); + std::fs::copy(proj.join("Gemfile.lock"), proj.join("Gemfile.next.lock")).unwrap(); + let cfg = bundle( + &proj, + &argv(&bundler.config_local_args("gemfile", "Gemfile.next")), + false, + ); + assert!(cfg.status.success(), "bundle config set --local gemfile"); + assert_vendor_refuses_unloaded_gemfile( + &proj, + &purl, + "Gemfile.next", + &[ + "Gemfile", + "Gemfile.lock", + "Gemfile.next", + "Gemfile.next.lock", + ], + ); +} diff --git a/crates/socket-patch-core/src/formats/gem/manifest.rs b/crates/socket-patch-core/src/formats/gem/manifest.rs index a48ea2e1a..f40f50ee3 100644 --- a/crates/socket-patch-core/src/formats/gem/manifest.rs +++ b/crates/socket-patch-core/src/formats/gem/manifest.rs @@ -39,7 +39,9 @@ impl GemfileSetting { pub fn describe(self) -> &'static str { match self { GemfileSetting::Env => "the BUNDLE_GEMFILE environment variable", - GemfileSetting::AppConfig => "BUNDLE_GEMFILE in the bundler app config (.bundle/config)", + GemfileSetting::AppConfig => { + "BUNDLE_GEMFILE in the bundler app config (.bundle/config)" + } } } } @@ -147,7 +149,11 @@ pub fn classify( }, }; let display = value.display().to_string(); - let absolute = |p: &Path| std::path::absolute(p).ok().and_then(|p| normalize_lexically(&p)); + let absolute = |p: &Path| { + std::path::absolute(p) + .ok() + .and_then(|p| normalize_lexically(&p)) + }; let target = if value.is_absolute() { absolute(&value) } else { @@ -161,10 +167,7 @@ pub fn classify( } } } - LoadedManifest::Unsupported { - value: display, - by, - } + LoadedManifest::Unsupported { value: display, by } } #[cfg(test)] @@ -251,7 +254,9 @@ mod tests { #[test] fn config_gemfile_reads_bundlers_own_spelling() { assert_eq!( - config_gemfile("---\nBUNDLE_PATH: \"vendor/bundle\"\nBUNDLE_GEMFILE: \"Gemfile.next\"\n"), + config_gemfile( + "---\nBUNDLE_PATH: \"vendor/bundle\"\nBUNDLE_GEMFILE: \"Gemfile.next\"\n" + ), Some("Gemfile.next".into()) ); assert_eq!(config_gemfile("---\nBUNDLE_GEMFILE: \"\"\n"), None); diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 5cf80cd9d..00acfc3bf 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -1703,8 +1703,16 @@ mod tests { "{:?}", done.rewrite.files.keys() ); - let codes: Vec<&str> = done.rewrite.warnings.iter().map(|w| w.code.as_str()).collect(); - assert!(codes.contains(&"redirect_gem_bundle_gemfile_unsupported"), "{codes:?}"); + let codes: Vec<&str> = done + .rewrite + .warnings + .iter() + .map(|w| w.code.as_str()) + .collect(); + assert!( + codes.contains(&"redirect_gem_bundle_gemfile_unsupported"), + "{codes:?}" + ); assert!(!codes.contains(&"redirect_gem_no_gemfile"), "{codes:?}"); } @@ -1720,7 +1728,11 @@ mod tests { p.insert_text("gems.locked", GEM_LOCK); p.insert_text(".bundle/config", "---\nBUNDLE_GEMFILE: \"Gemfile\"\n"); let (_read, done) = gem_rewrite(&p).await; - assert!(done.rewrite.files.contains_key("Gemfile"), "{:?}", done.rewrite.files.keys()); + assert!( + done.rewrite.files.contains_key("Gemfile"), + "{:?}", + done.rewrite.files.keys() + ); assert!(!done.rewrite.files.contains_key("gems.rb")); assert!(!done.rewrite.files.contains_key("gems.locked")); } @@ -1736,7 +1748,11 @@ mod tests { p.insert_text("gems.locked", GEM_LOCK); let (read, done) = gem_rewrite(&p).await; assert!(read.files.contains_key("Gemfile")); - assert!(done.rewrite.files.contains_key("gems.rb"), "{:?}", done.rewrite.files.keys()); + assert!( + done.rewrite.files.contains_key("gems.rb"), + "{:?}", + done.rewrite.files.keys() + ); assert!(!done.rewrite.files.contains_key("Gemfile")); } diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index a9c079f70..87d518f7a 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -2642,8 +2642,12 @@ mod tests { #[tokio::test] async fn gems_rb_twin_is_refused_before_any_write() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; - tokio::fs::write(root.join("gems.rb"), GEMFILE_DIRECT).await.unwrap(); - tokio::fs::write(root.join("gems.locked"), LOCK_DIRECT).await.unwrap(); + tokio::fs::write(root.join("gems.rb"), GEMFILE_DIRECT) + .await + .unwrap(); + tokio::fs::write(root.join("gems.locked"), LOCK_DIRECT) + .await + .unwrap(); let (code, detail) = unwrap_refused(run_vendor(&root, &blobs, &installed, &record, false).await); @@ -2655,7 +2659,10 @@ mod tests { ("gems.rb", GEMFILE_DIRECT), ("gems.locked", LOCK_DIRECT), ] { - assert_eq!(tokio::fs::read_to_string(root.join(file)).await.unwrap(), want); + assert_eq!( + tokio::fs::read_to_string(root.join(file)).await.unwrap(), + want + ); } assert!(!root.join(".socket/vendor").exists()); } @@ -2666,9 +2673,15 @@ mod tests { #[tokio::test] async fn bundle_gemfile_naming_another_manifest_is_refused() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; - tokio::fs::write(root.join("Gemfile.next"), GEMFILE_DIRECT).await.unwrap(); - tokio::fs::write(root.join("Gemfile.next.lock"), LOCK_DIRECT).await.unwrap(); - tokio::fs::create_dir_all(root.join(".bundle")).await.unwrap(); + tokio::fs::write(root.join("Gemfile.next"), GEMFILE_DIRECT) + .await + .unwrap(); + tokio::fs::write(root.join("Gemfile.next.lock"), LOCK_DIRECT) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join(".bundle")) + .await + .unwrap(); tokio::fs::write( root.join(".bundle/config"), "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n", @@ -2685,7 +2698,9 @@ mod tests { GEMFILE_DIRECT ); assert_eq!( - tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), LOCK_DIRECT ); assert!(!root.join(".socket/vendor").exists()); @@ -2696,8 +2711,12 @@ mod tests { #[tokio::test] async fn bundle_gemfile_naming_the_gemfile_overrides_a_gems_rb_twin() { let (_tmp, root, installed, blobs, record) = fixture(GEMFILE_DIRECT, LOCK_DIRECT).await; - tokio::fs::write(root.join("gems.rb"), GEMFILE_DIRECT).await.unwrap(); - tokio::fs::create_dir_all(root.join(".bundle")).await.unwrap(); + tokio::fs::write(root.join("gems.rb"), GEMFILE_DIRECT) + .await + .unwrap(); + tokio::fs::create_dir_all(root.join(".bundle")) + .await + .unwrap(); tokio::fs::write( root.join(".bundle/config"), "---\nBUNDLE_GEMFILE: \"Gemfile\"\n", @@ -2709,11 +2728,15 @@ mod tests { unwrap_done(run_vendor(&root, &blobs, &installed, &record, false).await); assert!(result.success, "vendor failed: {:?}", result.error); assert_eq!( - tokio::fs::read_to_string(root.join(GEMFILE_LOCK)).await.unwrap(), + tokio::fs::read_to_string(root.join(GEMFILE_LOCK)) + .await + .unwrap(), expected_lock_direct() ); assert_eq!( - tokio::fs::read_to_string(root.join("gems.rb")).await.unwrap(), + tokio::fs::read_to_string(root.join("gems.rb")) + .await + .unwrap(), GEMFILE_DIRECT ); } diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 7a475455a..db6d0f421 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -17,7 +17,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | npm (`npm`) — pnpm / yarn / berry / bun / vlt | ✅ any install layout, vlt's `node_modules/.vlt` store included (every store copy, copy-on-write) | ✅ seven lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun text `bun.lock` lockfileVersion 0/1/2 and native binary `bun.lockb` revisions 1/2/3 (binary locks stay binary; text workspace vendoring requires lockfileVersion 2 — see [Bun compatibility](testing/bun-compatibility.md)), vlt `vlt-lock.json` lockfileVersion 0/1 (patched package directories for direct dependencies of the root or a workspace member; transitive targets refused — see [vlt notes](#npm-vlt-notes)). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml and legacy shrinkwrap.yaml (pnpm majors 1–12; block and flow resolutions), yarn classic, yarn berry, bun, vlt (`vlt-lock.json` without `lockfileVersion`, 0 or 1) — pnpm, berry, bun and vlt carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) and [vlt notes](#npm-vlt-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ in place | ✅ uv project/script locks, PEP 751 `pylock.toml` / `pylock..toml`, poetry, pdm, pipenv (Pipenv 2018 or later — every `Pipfile.lock` category is rewired, lock-only checkouts included; Pipenv 2023+ does not hash-check local wheels — `vendor_integrity_unverified`; a venv still holding the upstream release is reported as `pypi_pipenv_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)), and requirements.txt. Native uv vendoring requires uv ≥ 0.2.35 (the `[[package]]` lock grammar); hosted mode covers native `uv.lock` from uv 0.1.45 (the first release whose `uv lock` writes one) and requirements from uv 0.0.5; see [uv compatibility](testing/uv-compatibility.md). | ✅ requirements.txt including hash continuations, uv project/script locks, and PEP 751 locks. Version/source ambiguity is refused; see [uv compatibility](testing/uv-compatibility.md). Poetry 1.x and 2.x locks are supported; Poetry 0.x ignores URL sources and is refused. See [Poetry compatibility](testing/poetry-compatibility.md). Pipenv `Pipfile.lock` (pipfile-spec 6 — Pipenv 7 and later; `path` references for 7–11, `file` from 2018; lock-only checkouts and Pipenv's out-of-tree venv are discovered; a warm venv that Pipenv will not reinstall over warns `redirect_pypi_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)). `pdm.lock` is supported for the lock formats PDM 0.12–1.4 and 2.8.1+ write (`lock_version` 2 / 4.3–4.5.1); the identity-losing 3.1 / 4.0–4.2 formats (PDM 1.8–2.7) are refused. PDM 2.8.0 writes an indistinguishable `4.3` lock but shares that identity-loss bug, so a rewritten 2.8.0 lock crashes `pdm sync` — upgrade to ≥ 2.8.1. See [PDM compatibility](testing/pdm-compatibility.md). | | Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry in the root `Cargo.toml` (v5; per-version Socket keys; pre-v5 `.cargo/config*` wiring migrates on re-run) | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum); direct dependencies only — a crate another dependency also pulls in is refused, use `--mode vendored`; with no `Cargo.lock` the graph is unknown, so only a project whose sole dependency is the patched crate is redirected | -| RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` project cannot vendor yet) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | +| RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — when bundler loads anything else, a `gems.rb` twin or a `BUNDLE_GEMFILE`-configured manifest, vendoring is refused with `gemfile_not_loaded` before any write) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`; `BUNDLE_GEMFILE` from the environment or `.bundle/config` is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | | Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [Go notes](#go-directory-replaces-and-gosum). Paid hosted patches are unsupported; `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) | ✅ in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ single-POM repository, suffixed Maven reactor repository, or Gradle 6.8+ same-GAV repository with settings wiring and SHA-256 checks; see [JVM vendoring](design/maven-vendoring.md) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) | | NuGet (`nuget`) | ✅ in-place patching deletes `.nupkg.metadata` and advises on the `.nupkg.sha512` tamper-evidence sidecar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed folder feed + `packageSourceMapping` + `packages.lock.json` contentHash pin | ✅ `nuget.config` source + source-mapping, `packages.lock.json` contentHash rewrite. See the locked-mode note in [Maven & NuGet caveats](#maven--nuget-caveats) | From 3e11f13158d6fa8d22146fc77f0aa5915e0e8329 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 06:51:31 +0000 Subject: [PATCH 5/8] Refuse a gems.rb vendor before the hosted takeover A hosted gems.rb project that is then vendored hit the hosted to vendored takeover first: the takeover restored the upstream entry, then vendoring refused gems.rb, leaving the gem unpatched in both modes. The takeover now runs the gem manifest check first, like the yarn berry preflight, so a refused gem keeps its hosted pin. Refs #341 Assisted-by: Claude Code:claude-opus-5-5 --- .../socket-patch-cli/src/commands/vendor.rs | 15 +++++ .../tests/e2e_redirect_gem_build.rs | 61 +++++++++++++++++++ crates/socket-patch-core/src/vendor/gem.rs | 59 ++++++++++-------- 3 files changed, 108 insertions(+), 27 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 4eae3aab2..fcf931bbd 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -2351,6 +2351,21 @@ pub(crate) async fn vendor_records_reusing( // restore, raised HERE instead — the same `failed` event, // code and detail, in the dry run and the wet run alike — // so the hosted wiring stays untouched. + // The gem backend's manifest refusal, likewise raised before + // the restore (a hosted `gems.rb` project cannot vendor). + if candidate.starts_with("pkg:gem/") { + if let Some((code, detail)) = + socket_patch_core::vendor::gem::gem_manifest_refusal(&common.cwd).await + { + has_errors = true; + env.record( + PatchEvent::new(PatchAction::Failed, candidate.clone()) + .with_error(code, detail.clone()), + ); + report_vendor_failure(common, candidate, &detail); + continue; + } + } if candidate.starts_with("pkg:npm/") { let refusal = berry_takeover_refusal .get_or_init(|| { diff --git a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs index d7e31dfe1..fe874c013 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_gem_build.rs @@ -1468,6 +1468,67 @@ async fn gem_hosted_gems_rb_spelling_redirects_and_installs() { manifestless_vex_matrix(&fx, &fresh).await; } +/// #341 follow-through: a CHECKSUMS-converged hosted `gems.rb` pin is a +/// live hosted pin, so `get --mode vendored` takes it over. Vendored mode +/// cannot wire `gems.rb`, and the refusal must come before the takeover +/// reverts the pin. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "host capstone: shells out to a real ruby/gem/bundler (>= 1.17; CHECKSUMS arm >= 2.6); \ + the unpinned `test` job skips it, an e2e job with a pinned toolchain runs it via --ignored"] +async fn gem_hosted_gems_rb_pin_survives_a_refused_vendored_takeover() { + let Some(fx) = redirect_scanned_project( + "gems.rb takeover", + Spelling::GemsRb, + true, + true, + None, + Driver::ScanVex, + ) + .await + else { + return; + }; + vendor_takeover_keeps_the_hosted_gems_rb_pin(&fx); +} + +/// A hosted→vendored takeover of a `gems.rb` project: vendored mode cannot +/// wire `gems.rb`, so `get --mode vendored` must refuse BEFORE it restores the hosted +/// pin's upstream entry, or the gem ends up unpatched in both modes. +fn vendor_takeover_keeps_the_hosted_gems_rb_pin(fx: &RedirectFixture) { + let before: Vec> = ["gems.rb", "gems.locked"] + .iter() + .map(|f| std::fs::read(fx.proj.join(f)).unwrap()) + .collect(); + let proj = fx.proj.to_str().expect("utf8 tmp path"); + let api = fx._server.uri(); + let (code, stdout, stderr) = run_socket( + &fx.proj, + &[ + "get", UUID, "--mode", "vendored", "--json", "--yes", "--cwd", proj, "--api-url", + &api, "--org", ORG, "--api-token", "fake", + // The mock serves the patch registry: its origin is the one a + // hosted pin is trusted on. + "--patch-server-url", &api, + ], + ); + assert_ne!(code, 0, "vendor must refuse.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("gemfile_not_loaded"), + "the manifest refusal names its cause:\n{stdout}" + ); + assert!( + !stdout.contains("vendor_takeover_reverted_redirect"), + "the hosted pin must not be reverted first:\n{stdout}" + ); + for (file, before) in ["gems.rb", "gems.locked"].iter().zip(before) { + assert_eq!( + std::fs::read(fx.proj.join(file)).unwrap(), + before, + "{file} keeps its hosted wiring" + ); + } +} + /// #390: bundler's `BUNDLE_GEMFILE` (here a committed `.bundle/config` /// naming `Gemfile.next`, the dual-boot layout) picks the manifest it /// loads. The hosted scan used to rewrite the ignored `Gemfile`, report diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 87d518f7a..1f628155f 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -136,6 +136,36 @@ struct GemPrelude { copy_ok: bool, } +/// Why vendored mode must not wire this project's Gemfile, if it must not: +/// bundler loads a different manifest. This backend edits the `Gemfile` + +/// `Gemfile.lock` pair, so a project where bundler loads `gems.rb` (it wins +/// over a Gemfile twin) or a `BUNDLE_GEMFILE`-configured manifest is refused +/// before any write: wiring the ignored Gemfile would report success while +/// bundler installs the upstream gem. The CLI's hosted→vendored takeover +/// asks this BEFORE it reverts a live hosted pin, so a refused gem keeps +/// its hosted wiring instead of ending up unpatched in both modes. +pub async fn gem_manifest_refusal(project_root: &Path) -> Option<(&'static str, String)> { + let loaded = crate::formats::gem::manifest::loaded_manifest(project_root).await; + let gems_rb_present = tokio::fs::symlink_metadata(project_root.join("gems.rb")) + .await + .is_ok(); + match loaded.pair(gems_rb_present) { + Some((GEMFILE, GEMFILE_LOCK)) => None, + Some((manifest, lock)) => Some(( + "gemfile_not_loaded", + format!( + "bundler loads {manifest} + {lock}, not the Gemfile + Gemfile.lock pair \ + vendored mode wires (a gems.rb project cannot vendor yet); use hosted \ + mode, or remove gems.rb / gems.locked if the Gemfile is the real manifest" + ), + )), + None => Some(( + "gemfile_not_loaded", + loaded.unsupported_detail().unwrap_or_default(), + )), + } +} + async fn gem_prelude( purl: &str, installed_path: &Path, @@ -238,33 +268,8 @@ async fn gem_prelude( } // ── project files ──────────────────────────────────────────────────── - // Wire only the manifest bundler actually loads. This backend edits - // the `Gemfile` + `Gemfile.lock` pair, so a project where bundler loads - // `gems.rb` (it wins over a Gemfile twin) or a `BUNDLE_GEMFILE`-configured - // manifest is refused here, before any write: wiring the ignored Gemfile - // would report success while bundler installs the upstream gem. - let loaded = crate::formats::gem::manifest::loaded_manifest(project_root).await; - let gems_rb_present = tokio::fs::symlink_metadata(project_root.join("gems.rb")) - .await - .is_ok(); - match loaded.pair(gems_rb_present) { - Some((GEMFILE, GEMFILE_LOCK)) => {} - Some((manifest, lock)) => { - return Err(refused( - "gemfile_not_loaded", - format!( - "bundler loads {manifest} + {lock}, not the Gemfile + Gemfile.lock pair \ - vendored mode wires (a gems.rb project cannot vendor yet); use hosted \ - mode, or remove gems.rb / gems.locked if the Gemfile is the real manifest" - ), - )); - } - None => { - return Err(refused( - "gemfile_not_loaded", - loaded.unsupported_detail().unwrap_or_default(), - )); - } + if let Some((code, detail)) = gem_manifest_refusal(project_root).await { + return Err(refused(code, detail)); } let gemfile_path = project_root.join(GEMFILE); let gemfile_text = match read_regular_to_string(&gemfile_path).await { From f7dd00f4b96a91183633dd5d417368d1a01961b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:09:38 +0000 Subject: [PATCH 6/8] Explain the gems.rb twin refusal per Bundler era Bundler 1.x reads a Gemfile before gems.rb, while 2.x and later load gems.rb, so the vendored refusal of a gems.rb twin no longer claims that Bundler always loads gems.rb. The twin is still refused on every version, because vendor cannot tell which Bundler will install. The e2e premise now follows the host Bundler, which fixes the 1.17.3 leg. Refs #341 Assisted-by: Claude Code:claude-opus-5-5 --- .../tests/e2e_vendor_gem_build.rs | 17 +++++++++++----- .../src/formats/gem/manifest.rs | 6 ++++-- crates/socket-patch-core/src/vendor/gem.rs | 20 ++++++++++++++++--- docs/ecosystems.md | 2 +- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs index 1dd1d75bc..fa81746f8 100644 --- a/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs +++ b/crates/socket-patch-cli/tests/e2e_vendor_gem_build.rs @@ -1447,11 +1447,11 @@ fn staged_rack_project( Some((tmp, proj, bundler, purl)) } -/// Run `vendor` on a project where bundler loads a manifest other than the -/// Gemfile, and assert the refusal: a `gemfile_not_loaded` failure for the +/// Run `vendor` on a project whose manifest choice vendored mode cannot +/// follow (`loaded` is what the host bundler loads), and assert the refusal: a `gemfile_not_loaded` failure for the /// gem, and every manifest and lock byte-untouched. fn assert_vendor_refuses_unloaded_gemfile(proj: &Path, purl: &str, loaded: &str, files: &[&str]) { - // The premise: real bundler loads `loaded`, not the Gemfile. + // The premise: the host bundler loads `loaded`. let probe = bundle( proj, &["exec", "ruby", "-e", "puts Bundler.default_gemfile"], @@ -1512,15 +1512,22 @@ fn assert_vendor_refuses_unloaded_gemfile(proj: &Path, purl: &str, loaded: &str, #[ignore = "host capstone: shells out to a real bundler >= 1.17; the unpinned `test` job \ skips it, the e2e job runs it with a pinned toolchain via --ignored"] fn gem_vendor_refuses_a_gems_rb_twin() { - let Some((_tmp, proj, _bundler, purl)) = staged_rack_project("gems.rb twin") else { + let Some((_tmp, proj, bundler, purl)) = staged_rack_project("gems.rb twin") else { return; }; std::fs::copy(proj.join("Gemfile"), proj.join("gems.rb")).unwrap(); std::fs::copy(proj.join("Gemfile.lock"), proj.join("gems.locked")).unwrap(); + // bundler >= 2 loads gems.rb; 1.x still reads the Gemfile first. The + // twin is refused either way (vendor cannot tell which bundler runs). + let loaded = if bundler.at_least(2, 0) { + "gems.rb" + } else { + "Gemfile" + }; assert_vendor_refuses_unloaded_gemfile( &proj, &purl, - "gems.rb", + loaded, &["Gemfile", "Gemfile.lock", "gems.rb", "gems.locked"], ); } diff --git a/crates/socket-patch-core/src/formats/gem/manifest.rs b/crates/socket-patch-core/src/formats/gem/manifest.rs index f40f50ee3..07ba01a6f 100644 --- a/crates/socket-patch-core/src/formats/gem/manifest.rs +++ b/crates/socket-patch-core/src/formats/gem/manifest.rs @@ -10,7 +10,9 @@ //! 2. `BUNDLE_GEMFILE:` in the app config file, `$BUNDLE_APP_CONFIG/config` //! else `/.bundle/config` (what `bundle config set --local gemfile //! Gemfile.next` writes; relative to the project root); -//! 3. otherwise `gems.rb` when present, else `Gemfile`. +//! 3. otherwise `gems.rb` when present, else `Gemfile` (bundler >= 2; 1.x +//! reads a `Gemfile` first, so callers treat a twin as ambiguous or +//! follow the >= 2 order, as the hosted rewriter does). //! //! A configured value that names the root's own `Gemfile` or `gems.rb` is //! that spelling; anything else (`Gemfile.next`, a file in another @@ -49,7 +51,7 @@ impl GemfileSetting { /// The manifest Bundler loads for a project root. #[derive(Debug, Clone, PartialEq, Eq)] pub enum LoadedManifest { - /// No `BUNDLE_GEMFILE`: Bundler's default discovery (`gems.rb` first, + /// No `BUNDLE_GEMFILE`: Bundler's default discovery (bundler >= 2: `gems.rb` first, /// then `Gemfile`), which callers apply to the files they see. Default, /// `BUNDLE_GEMFILE` names the root's own `Gemfile` or `gems.rb`. diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 1f628155f..7a178ff01 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -145,18 +145,32 @@ struct GemPrelude { /// asks this BEFORE it reverts a live hosted pin, so a refused gem keeps /// its hosted wiring instead of ending up unpatched in both modes. pub async fn gem_manifest_refusal(project_root: &Path) -> Option<(&'static str, String)> { + use crate::formats::gem::manifest::LoadedManifest; let loaded = crate::formats::gem::manifest::loaded_manifest(project_root).await; let gems_rb_present = tokio::fs::symlink_metadata(project_root.join("gems.rb")) .await .is_ok(); match loaded.pair(gems_rb_present) { Some((GEMFILE, GEMFILE_LOCK)) => None, + // A `gems.rb` twin is refused whatever bundler runs: bundler >= 2 + // loads `gems.rb` (with a "Multiple gemfiles" warning) while 1.x + // still reads the Gemfile first, so wiring the Gemfile is only + // right on a bundler this backend cannot see. + Some((manifest, lock)) if matches!(loaded, LoadedManifest::Default) => Some(( + "gemfile_not_loaded", + format!( + "a {manifest} sits beside the Gemfile and bundler >= 2 loads {manifest} + \ + {lock} instead of the Gemfile + Gemfile.lock pair vendored mode wires (a \ + gems.rb project cannot vendor yet); use hosted mode, or remove gems.rb / \ + gems.locked if the Gemfile is the real manifest" + ), + )), Some((manifest, lock)) => Some(( "gemfile_not_loaded", format!( - "bundler loads {manifest} + {lock}, not the Gemfile + Gemfile.lock pair \ - vendored mode wires (a gems.rb project cannot vendor yet); use hosted \ - mode, or remove gems.rb / gems.locked if the Gemfile is the real manifest" + "BUNDLE_GEMFILE makes bundler load {manifest} + {lock}, not the Gemfile + \ + Gemfile.lock pair vendored mode wires (a gems.rb project cannot vendor yet); \ + use hosted mode" ), )), None => Some(( diff --git a/docs/ecosystems.md b/docs/ecosystems.md index db6d0f421..943bf0186 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -17,7 +17,7 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. | npm (`npm`) — pnpm / yarn / berry / bun / vlt | ✅ any install layout, vlt's `node_modules/.vlt` store included (every store copy, copy-on-write) | ✅ seven lockfile flavors: package-lock, yarn classic, yarn berry (node-modules linker; PnP refused), pnpm v9, pnpm legacy v5.4/v6.0 (`pnpm 7/8` — frozen installs are path-bound because those majors absolutize `file:` override specifiers; moved checkouts run one `pnpm install --offline --no-frozen-lockfile`, surfaced as `vendor_pnpm_legacy_absolute_specifier`), bun text `bun.lock` lockfileVersion 0/1/2 and native binary `bun.lockb` revisions 1/2/3 (binary locks stay binary; text workspace vendoring requires lockfileVersion 2 — see [Bun compatibility](testing/bun-compatibility.md)), vlt `vlt-lock.json` lockfileVersion 0/1 (patched package directories for direct dependencies of the root or a workspace member; transitive targets refused — see [vlt notes](#npm-vlt-notes)). Rush monorepos refused (`vendor_rush_unsupported`) — see [Rush notes](#npm-rush-monorepos) | ✅ package-lock / npm-shrinkwrap, pnpm-lock.yaml and legacy shrinkwrap.yaml (pnpm majors 1–12; block and flow resolutions), yarn classic, yarn berry, bun, vlt (`vlt-lock.json` without `lockfileVersion`, 0 or 1) — pnpm, berry, bun and vlt carry constraints, see [npm hosted-mode notes](#npm-hosted-mode-notes) and [vlt notes](#npm-vlt-notes) | | PyPI (`pypi`) — uv / poetry / pdm / pipenv / pip | ✅ in place | ✅ uv project/script locks, PEP 751 `pylock.toml` / `pylock..toml`, poetry, pdm, pipenv (Pipenv 2018 or later — every `Pipfile.lock` category is rewired, lock-only checkouts included; Pipenv 2023+ does not hash-check local wheels — `vendor_integrity_unverified`; a venv still holding the upstream release is reported as `pypi_pipenv_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)), and requirements.txt. Native uv vendoring requires uv ≥ 0.2.35 (the `[[package]]` lock grammar); hosted mode covers native `uv.lock` from uv 0.1.45 (the first release whose `uv lock` writes one) and requirements from uv 0.0.5; see [uv compatibility](testing/uv-compatibility.md). | ✅ requirements.txt including hash continuations, uv project/script locks, and PEP 751 locks. Version/source ambiguity is refused; see [uv compatibility](testing/uv-compatibility.md). Poetry 1.x and 2.x locks are supported; Poetry 0.x ignores URL sources and is refused. See [Poetry compatibility](testing/poetry-compatibility.md). Pipenv `Pipfile.lock` (pipfile-spec 6 — Pipenv 7 and later; `path` references for 7–11, `file` from 2018; lock-only checkouts and Pipenv's out-of-tree venv are discovered; a warm venv that Pipenv will not reinstall over warns `redirect_pypi_stale_install`; see [Pipenv compatibility](testing/pipenv-compatibility.md)). `pdm.lock` is supported for the lock formats PDM 0.12–1.4 and 2.8.1+ write (`lock_version` 2 / 4.3–4.5.1); the identity-losing 3.1 / 4.0–4.2 formats (PDM 1.8–2.7) are refused. PDM 2.8.0 writes an indistinguishable `4.3` lock but shares that identity-loss bug, so a rewritten 2.8.0 lock crashes `pdm sync` — upgrade to ≥ 2.8.1. See [PDM compatibility](testing/pdm-compatibility.md). | | Cargo (`cargo`) | ✅ in-place + `.cargo-checksum.json` rewrite (shared registry-cache caveat — see [Cargo: shared registry cache](#cargo-shared-registry-cache)) | ✅ `[patch.crates-io]` path entry in the root `Cargo.toml` (v5; per-version Socket keys; pre-v5 `.cargo/config*` wiring migrates on re-run) | ✅ per-patch sparse registry (`[registries.socket-patch-]` + Cargo.lock source/checksum); direct dependencies only — a crate another dependency also pulls in is refused, use `--mode vendored`; with no `Cargo.lock` the graph is unknown, so only a project whose sole dependency is the patched crate is redirected | -| RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — when bundler loads anything else, a `gems.rb` twin or a `BUNDLE_GEMFILE`-configured manifest, vendoring is refused with `gemfile_not_loaded` before any write) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`; `BUNDLE_GEMFILE` from the environment or `.bundle/config` is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | +| RubyGems (`gem`) | ✅ in place | ✅ Gemfile + Gemfile.lock path pair (`Gemfile` spelling only — a `gems.rb` twin, which bundler ≥ 2 loads instead, or a `BUNDLE_GEMFILE`-configured manifest makes vendoring refuse with `gemfile_not_loaded` before any write) | ✅ per-dep `source` block — edits `gems.rb` + `gems.locked` when present (bundler prefers them over `Gemfile`; spellings that diverge beyond Socket's own edits fail closed with `redirect_gem_gemfile_spellings_diverge`; `BUNDLE_GEMFILE` from the environment or `.bundle/config` is followed when it names the project's `Gemfile` / `gems.rb`, and any other configured manifest is refused with `redirect_gem_bundle_gemfile_unsupported`); the `CHECKSUMS` pin needs bundler ≥ 2.6 (older locks get a `redirect_gem_no_checksums_section` warning); a stale pre-redirect materialization that `bundle install` would reuse instead of refetching is flagged `redirect_gem_stale_install` with a prescriptive remedy (see CLI_CONTRACT.md's "Gem stale-install guard") | | Go (`golang`) | ✅ `go.mod` `replace` → `.socket/go-patches/` — see [Go: directory replaces and go.sum](#go-directory-replaces-and-gosum) | ✅ `replace` → the committed vendor tree | ✅ (free tier) fork-style `replace` → `patch.socket.dev/gopatch/` + committed `go.sum` pin; see [Go notes](#go-directory-replaces-and-gosum). Paid hosted patches are unsupported; `redirect_golang_unsupported` names the vendored remedy | | Maven (`maven`) | ✅ in-place jar patching leaves the `~/.m2` checksum sidecars stale — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ single-POM repository, suffixed Maven reactor repository, or Gradle 6.8+ same-GAV repository with settings wiring and SHA-256 checks; see [JVM vendoring](design/maven-vendoring.md) | ✅ **pom projects only, fail-closed** — the patched jar is pinned at a Socket-only `-socket.` suffix; `${property}` versions are refused; Gradle gets a manual `exclusiveContent` snippet — see [Maven & NuGet caveats](#maven--nuget-caveats) | | NuGet (`nuget`) | ✅ in-place patching deletes `.nupkg.metadata` and advises on the `.nupkg.sha512` tamper-evidence sidecar — prefer vendored / hosted, see [Maven & NuGet caveats](#maven--nuget-caveats) | ✅ committed folder feed + `packageSourceMapping` + `packages.lock.json` contentHash pin | ✅ `nuget.config` source + source-mapping, `packages.lock.json` contentHash rewrite. See the locked-mode note in [Maven & NuGet caveats](#maven--nuget-caveats) | From ffbe645a84bdf592da042ebe0c2e3292c6ceba2e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:18:06 +0000 Subject: [PATCH 7/8] Keep the Bundler manifest model free of I/O The formats modules are pure by contract, and an architecture test enforces that. Move the disk and environment reads for the manifest Bundler loads into the ruby crawler, next to the BUNDLE_PATH config reader. formats::gem::manifest keeps only the classification. Refs #341, #390 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/ruby_crawler.rs | 55 +++++++++++++++++++ .../src/formats/gem/manifest.rs | 55 ++----------------- crates/socket-patch-core/src/hosted/engine.rs | 2 +- crates/socket-patch-core/src/vendor/gem.rs | 2 +- 4 files changed, 61 insertions(+), 53 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index f3dc7292b..1696f7dd7 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -944,6 +944,34 @@ fn expand_tilde(value: &Path, home: Option<&Path>) -> PathBuf { value.to_path_buf() } +/// [`crate::formats::gem::manifest::classify`] for `root` on disk: the +/// manifest bundler loads, reading the ambient `BUNDLE_GEMFILE` / +/// `BUNDLE_APP_CONFIG` and the app config file. +pub async fn bundler_loaded_manifest(root: &Path) -> crate::formats::gem::manifest::LoadedManifest { + bundler_loaded_manifest_with_env( + root, + std::env::var_os("BUNDLE_GEMFILE").as_deref(), + std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), + ) + .await +} + +/// [`bundler_loaded_manifest`] with the environment passed explicitly (hermetic +/// tests). +pub async fn bundler_loaded_manifest_with_env( + root: &Path, + gemfile_env: Option<&OsStr>, + app_config_env: Option<&OsStr>, +) -> crate::formats::gem::manifest::LoadedManifest { + let config = bundler_app_config_dir(root, app_config_env).join("config"); + let config_value = crate::utils::fs::read_regular_to_string(&config) + .await + .ok() + .and_then(|text| crate::formats::gem::manifest::config_gemfile(&text)); + let cwd = std::env::current_dir().unwrap_or_default(); + crate::formats::gem::manifest::classify(root, &cwd, gemfile_env, config_value.as_deref()) +} + /// Bundler's app-config dir for `root`, following `Bundler.app_config_path` /// exactly: `$BUNDLE_APP_CONFIG` when set (a relative value resolves against /// the project root, NOT the process cwd), else `/.bundle` — e.g. the @@ -1097,6 +1125,33 @@ fn is_safe_gem_coordinate(name: &str, version: &str) -> bool { mod tests { use super::*; + #[tokio::test] + async fn loaded_manifest_reads_the_app_config_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".bundle")).unwrap(); + std::fs::write( + dir.path().join(".bundle/config"), + "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n", + ) + .unwrap(); + let m = bundler_loaded_manifest_with_env(dir.path(), None, None).await; + assert!(matches!( + m, + crate::formats::gem::manifest::LoadedManifest::Unsupported { + by: crate::formats::gem::manifest::GemfileSetting::AppConfig, + .. + } + )); + // BUNDLE_APP_CONFIG moves the config file away from `.bundle`. + let m = bundler_loaded_manifest_with_env( + dir.path(), + None, + Some(std::ffi::OsStr::new("elsewhere")), + ) + .await; + assert_eq!(m, crate::formats::gem::manifest::LoadedManifest::Default); + } + #[test] fn test_parse_gem_dir_name() { assert_eq!( diff --git a/crates/socket-patch-core/src/formats/gem/manifest.rs b/crates/socket-patch-core/src/formats/gem/manifest.rs index 07ba01a6f..d28939ea9 100644 --- a/crates/socket-patch-core/src/formats/gem/manifest.rs +++ b/crates/socket-patch-core/src/formats/gem/manifest.rs @@ -19,12 +19,14 @@ //! directory, a missing file) is [`LoadedManifest::Unsupported`]: the //! rewriters and the lock readers only know the two default pairs, so the //! callers fail closed rather than wire a manifest Bundler never reads. -//! The user-level `~/.bundle/config` is not consulted. +//! The user-level `~/.bundle/config` is not consulted. The model is pure: +//! the disk and environment reads live in +//! [`crate::crawlers::ruby_crawler::bundler_loaded_manifest`]. use std::ffi::OsStr; use std::path::{Path, PathBuf}; -use crate::crawlers::ruby_crawler::{bundler_app_config_dir, unquote_bundle_config_value}; +use crate::crawlers::ruby_crawler::unquote_bundle_config_value; use crate::utils::fs::normalize_lexically; /// Where a configured `BUNDLE_GEMFILE` came from. @@ -94,33 +96,6 @@ impl LoadedManifest { } } -/// [`classify`] for `root` on disk, reading the ambient `BUNDLE_GEMFILE` / -/// `BUNDLE_APP_CONFIG` and the app config file. -pub async fn loaded_manifest(root: &Path) -> LoadedManifest { - loaded_manifest_with_env( - root, - std::env::var_os("BUNDLE_GEMFILE").as_deref(), - std::env::var_os("BUNDLE_APP_CONFIG").as_deref(), - ) - .await -} - -/// [`loaded_manifest`] with the environment passed explicitly (hermetic -/// tests). -pub async fn loaded_manifest_with_env( - root: &Path, - gemfile_env: Option<&OsStr>, - app_config_env: Option<&OsStr>, -) -> LoadedManifest { - let config = bundler_app_config_dir(root, app_config_env).join("config"); - let config_value = crate::utils::fs::read_regular_to_string(&config) - .await - .ok() - .and_then(|text| config_gemfile(&text)); - let cwd = std::env::current_dir().unwrap_or_default(); - classify(root, &cwd, gemfile_env, config_value.as_deref()) -} - /// The `BUNDLE_GEMFILE:` value of a bundler app config file (flat YAML that /// bundler writes itself; an empty value counts as unset). pub fn config_gemfile(contents: &str) -> Option { @@ -264,26 +239,4 @@ mod tests { assert_eq!(config_gemfile("---\nBUNDLE_GEMFILE: \"\"\n"), None); assert_eq!(config_gemfile("---\nBUNDLE_PATH: \"x\"\n"), None); } - - #[tokio::test] - async fn loaded_manifest_reads_the_app_config_file() { - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir(dir.path().join(".bundle")).unwrap(); - std::fs::write( - dir.path().join(".bundle/config"), - "---\nBUNDLE_GEMFILE: \"Gemfile.next\"\n", - ) - .unwrap(); - let m = loaded_manifest_with_env(dir.path(), None, None).await; - assert!(matches!( - m, - LoadedManifest::Unsupported { - by: GemfileSetting::AppConfig, - .. - } - )); - // BUNDLE_APP_CONFIG moves the config file away from `.bundle`. - let m = loaded_manifest_with_env(dir.path(), None, Some(OsStr::new("elsewhere"))).await; - assert_eq!(m, LoadedManifest::Default); - } } diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index 00acfc3bf..d3c19b076 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -476,7 +476,7 @@ async fn keep_bundler_loaded_gem_files(view: &ProjectView<'_>, out: &mut Candida let loaded = match view { ProjectView::Disk(root) | ProjectView::Snapshot(crate::vendor::lock_inventory::DiskSnapshot { root, .. }) => { - manifest::loaded_manifest(root).await + crate::crawlers::ruby_crawler::bundler_loaded_manifest(root).await } ProjectView::Memory(_) => { let config = view.read_text(".bundle/config").await.ok(); diff --git a/crates/socket-patch-core/src/vendor/gem.rs b/crates/socket-patch-core/src/vendor/gem.rs index 7a178ff01..2aaa4e3d0 100644 --- a/crates/socket-patch-core/src/vendor/gem.rs +++ b/crates/socket-patch-core/src/vendor/gem.rs @@ -146,7 +146,7 @@ struct GemPrelude { /// its hosted wiring instead of ending up unpatched in both modes. pub async fn gem_manifest_refusal(project_root: &Path) -> Option<(&'static str, String)> { use crate::formats::gem::manifest::LoadedManifest; - let loaded = crate::formats::gem::manifest::loaded_manifest(project_root).await; + let loaded = crate::crawlers::ruby_crawler::bundler_loaded_manifest(project_root).await; let gems_rb_present = tokio::fs::symlink_metadata(project_root.join("gems.rb")) .await .is_ok(); From 522febb3ab2e8460e730fc198a2a6db7bef932da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 07:46:19 +0000 Subject: [PATCH 8/8] Anchor BUNDLE_GEMFILE at the project root A relative BUNDLE_GEMFILE was resolved against socket-patch's own working directory, so a --cwd run classified BUNDLE_GEMFILE=Gemfile as unsupported. Bundler expands the value in the directory bundle runs in, which is the project. The refusal also always said to unset the environment variable; for a .bundle/config setting it now gives the matching 'bundle config unset --local gemfile' remedy. Refs #390 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/crawlers/ruby_crawler.rs | 3 +- .../src/formats/gem/manifest.rs | 88 ++++++++++++------- crates/socket-patch-core/src/hosted/engine.rs | 2 +- 3 files changed, 59 insertions(+), 34 deletions(-) diff --git a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs index 1696f7dd7..d8c4faf52 100644 --- a/crates/socket-patch-core/src/crawlers/ruby_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/ruby_crawler.rs @@ -968,8 +968,7 @@ pub async fn bundler_loaded_manifest_with_env( .await .ok() .and_then(|text| crate::formats::gem::manifest::config_gemfile(&text)); - let cwd = std::env::current_dir().unwrap_or_default(); - crate::formats::gem::manifest::classify(root, &cwd, gemfile_env, config_value.as_deref()) + crate::formats::gem::manifest::classify(root, gemfile_env, config_value.as_deref()) } /// Bundler's app-config dir for `root`, following `Bundler.app_config_path` diff --git a/crates/socket-patch-core/src/formats/gem/manifest.rs b/crates/socket-patch-core/src/formats/gem/manifest.rs index d28939ea9..12644f3a1 100644 --- a/crates/socket-patch-core/src/formats/gem/manifest.rs +++ b/crates/socket-patch-core/src/formats/gem/manifest.rs @@ -5,8 +5,10 @@ //! Bundler's own order (`Bundler::SharedHelpers#default_gemfile` and the CLI's //! `gemfile` setting): //! -//! 1. `BUNDLE_GEMFILE` from the environment (a relative value expands -//! against the process cwd, as `File.expand_path` does); +//! 1. `BUNDLE_GEMFILE` from the environment (a relative value is read +//! against the project root: bundler expands it against the directory +//! `bundle` runs in, which is the project, not socket-patch's own +//! cwd when it runs with `--cwd`); //! 2. `BUNDLE_GEMFILE:` in the app config file, `$BUNDLE_APP_CONFIG/config` //! else `/.bundle/config` (what `bundle config set --local gemfile //! Gemfile.next` writes; relative to the project root); @@ -85,12 +87,23 @@ impl LoadedManifest { /// The detail line for a caller that refuses an unsupported manifest. pub fn unsupported_detail(&self) -> Option { match self { - LoadedManifest::Unsupported { value, by } => Some(format!( - "bundler loads `{value}` ({}), not the project's Gemfile or gems.rb; \ - socket-patch only wires those, so it left the gem manifests untouched (unset \ - BUNDLE_GEMFILE, or point it at the project's Gemfile, and re-run)", - by.describe() - )), + LoadedManifest::Unsupported { value, by } => { + let remedy = match by { + GemfileSetting::Env => { + "unset BUNDLE_GEMFILE, or point it at the project's Gemfile" + } + GemfileSetting::AppConfig => { + "run `bundle config unset --local gemfile`, or point it at the \ + project's Gemfile" + } + }; + Some(format!( + "bundler loads `{value}` ({}), not the project's Gemfile or gems.rb; \ + socket-patch only wires those, so it left the gem manifests untouched \ + ({remedy}, and re-run)", + by.describe() + )) + } _ => None, } } @@ -110,18 +123,16 @@ pub fn config_gemfile(contents: &str) -> Option { } /// Classify the configured `BUNDLE_GEMFILE` (environment first, then the -/// app config value) against `root`. `cwd` anchors a relative environment -/// value; a relative config value is anchored at `root`. +/// app config value) against `root`, which also anchors a relative value. pub fn classify( root: &Path, - cwd: &Path, gemfile_env: Option<&OsStr>, config_value: Option<&str>, ) -> LoadedManifest { - let (value, base, by) = match gemfile_env.filter(|v| !v.is_empty()) { - Some(v) => (PathBuf::from(v), cwd, GemfileSetting::Env), + let (value, by) = match gemfile_env.filter(|v| !v.is_empty()) { + Some(v) => (PathBuf::from(v), GemfileSetting::Env), None => match config_value.filter(|v| !v.is_empty()) { - Some(v) => (PathBuf::from(v), root, GemfileSetting::AppConfig), + Some(v) => (PathBuf::from(v), GemfileSetting::AppConfig), None => return LoadedManifest::Default, }, }; @@ -134,7 +145,7 @@ pub fn classify( let target = if value.is_absolute() { absolute(&value) } else { - absolute(&base.join(&value)) + absolute(&root.join(&value)) }; let root = absolute(root); if let (Some(target), Some(root)) = (target, root) { @@ -157,20 +168,20 @@ mod tests { #[test] fn no_setting_is_bundlers_default_discovery() { - let m = classify(&root(), &root(), None, None); + let m = classify(&root(), None, None); assert_eq!(m, LoadedManifest::Default); assert_eq!(m.pair(true), Some(("gems.rb", "gems.locked"))); assert_eq!(m.pair(false), Some(("Gemfile", "Gemfile.lock"))); // An empty value is unset, as in bundler. assert_eq!( - classify(&root(), &root(), Some(OsStr::new("")), Some("")), + classify(&root(), Some(OsStr::new("")), Some("")), LoadedManifest::Default ); } #[test] fn config_naming_another_manifest_is_unsupported() { - let m = classify(&root(), &root(), None, Some("Gemfile.next")); + let m = classify(&root(), None, Some("Gemfile.next")); assert_eq!( m, LoadedManifest::Unsupported { @@ -186,23 +197,22 @@ mod tests { fn config_naming_the_default_spellings_selects_that_pair() { // `bundle config set --local gemfile Gemfile` beside a gems.rb: // bundler loads Gemfile + Gemfile.lock, not gems.rb. - let m = classify(&root(), &root(), None, Some("Gemfile")); + let m = classify(&root(), None, Some("Gemfile")); assert_eq!(m.pair(true), Some(("Gemfile", "Gemfile.lock"))); - let m = classify(&root(), &root(), None, Some("./gems.rb")); + let m = classify(&root(), None, Some("./gems.rb")); assert_eq!(m.pair(false), Some(("gems.rb", "gems.locked"))); let abs = root().join("Gemfile"); - let m = classify(&root(), &root(), None, Some(abs.to_str().unwrap())); + let m = classify(&root(), None, Some(abs.to_str().unwrap())); assert_eq!(m.pair(true), Some(("Gemfile", "Gemfile.lock"))); } + /// The environment wins over the app config, and a relative value is + /// read against the project root even when socket-patch runs elsewhere + /// with `--cwd` (Bugbot on #431: `BUNDLE_GEMFILE=Gemfile` must select + /// the project's Gemfile, not a file under the process cwd). #[test] - fn env_wins_over_config_and_expands_against_the_cwd() { - let m = classify( - &root(), - &root().join("sub"), - Some(OsStr::new("../Gemfile")), - Some("Gemfile.next"), - ); + fn env_wins_over_config_and_is_anchored_at_the_project_root() { + let m = classify(&root(), Some(OsStr::new("Gemfile")), Some("Gemfile.next")); assert_eq!( m, LoadedManifest::Configured { @@ -210,7 +220,7 @@ mod tests { by: GemfileSetting::Env } ); - let m = classify(&root(), &root(), Some(OsStr::new("Gemfile.next")), None); + let m = classify(&root(), Some(OsStr::new("Gemfile.next")), None); assert!(matches!( m, LoadedManifest::Unsupported { @@ -220,11 +230,27 @@ mod tests { )); } + /// The refusal names the remedy for the knob that set it: unsetting the + /// environment variable does nothing to a `.bundle/config` setting. + #[test] + fn unsupported_detail_names_the_knob_that_set_it() { + let env = classify(&root(), Some(OsStr::new("Gemfile.next")), None); + let env = env.unsupported_detail().unwrap(); + assert!(env.contains("unset BUNDLE_GEMFILE"), "{env}"); + let config = classify(&root(), None, Some("Gemfile.next")); + let config = config.unsupported_detail().unwrap(); + assert!( + config.contains("bundle config unset --local gemfile"), + "{config}" + ); + assert!(!config.contains("unset BUNDLE_GEMFILE"), "{config}"); + } + #[test] fn a_manifest_in_another_directory_is_unsupported() { - let m = classify(&root(), &root(), None, Some("../other/Gemfile")); + let m = classify(&root(), None, Some("../other/Gemfile")); assert!(matches!(m, LoadedManifest::Unsupported { .. })); - let m = classify(&root(), &root(), None, Some("sub/Gemfile")); + let m = classify(&root(), None, Some("sub/Gemfile")); assert!(matches!(m, LoadedManifest::Unsupported { .. })); } diff --git a/crates/socket-patch-core/src/hosted/engine.rs b/crates/socket-patch-core/src/hosted/engine.rs index d3c19b076..354b41e0c 100644 --- a/crates/socket-patch-core/src/hosted/engine.rs +++ b/crates/socket-patch-core/src/hosted/engine.rs @@ -482,7 +482,7 @@ async fn keep_bundler_loaded_gem_files(view: &ProjectView<'_>, out: &mut Candida let config = view.read_text(".bundle/config").await.ok(); let value = config.as_deref().and_then(manifest::config_gemfile); let root = std::path::Path::new("/"); - manifest::classify(root, root, None, value.as_deref()) + manifest::classify(root, None, value.as_deref()) } }; let keep: &[&str] = match &loaded {