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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/socket-patch-cli/CLI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,8 @@ the model is **not uniform** today:
crawler probes the project's Bundler install roots in **bundler's own precedence order** — the app
config file's `BUNDLE_PATH:` (`$BUNDLE_APP_CONFIG/config`, else `<cwd>/.bundle/config` — what
`bundle config set --local path` records), then the **`BUNDLE_PATH` environment variable**, then the
**standalone `<cwd>/bundle`** tree when it holds `bundle/bundler/setup.rb` (what `bundle install
--standalone` writes and the app loads; bundler 4 records no config for it), then the
default `<cwd>/vendor/bundle` — each in both store layouts bundler produces (scoped
`<root>/<engine>/<abi>/gems/` and flat `<root>/gems/`). The env variable is the user's own machine
state, so it is honored verbatim (it may point outside `--cwd`; a leading `~` expands against home);
Expand All @@ -496,7 +498,7 @@ the model is **not uniform** today:
`gem_bundle_config_path_ignored` entry in the run-level `warnings[]` of `scan`/`apply` `--json`
envelopes (detail names the config value and the env-`BUNDLE_PATH` remedy), and one stderr
`Warning: …` line on the human path, gated on `!--silent`
(`--silent` = errors only). Explicit env/config roots only count when `--cwd` holds a Bundler
(`--silent` = errors only). Explicit env/config/standalone roots only count when `--cwd` holds a Bundler
manifest/lockfile. When the default `vendor/bundle` root holds no store, the gem homes `gem env`
reports are appended (default gems like rexml/json only ever live there). When several roots hold
**coexisting physical copies of one `gem@version`** (bundler-2's scoped store beside bundler-1's
Expand Down
19 changes: 15 additions & 4 deletions crates/socket-patch-cli/src/commands/vex_consumed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -715,8 +715,11 @@ mod tests {
None,
)
.await;
assert_eq!(installed_again, installed);
let (paths, calls) = tracked_npm_hosted(&common, &installed_again).await;
// Since #605 the name-keyed resolver probes bundled trees itself, so
// it already returns the aliases and the nested store's peers. Feed
// the earlier, alias-free set to keep exercising alias expansion;
// the resolver's own set is checked against the same result below.
let (paths, calls) = tracked_npm_hosted(&common, &installed).await;
assert_eq!(calls.len(), 1);
let mut inputs = calls[0].clone();
inputs.sort();
Expand All @@ -738,6 +741,9 @@ mod tests {
.len(),
paths.len()
);
let (mut resolved, _) = tracked_npm_hosted(&common, &installed_again).await;
resolved.sort();
assert_eq!(resolved, expected, "the resolver's own copy set");
}

#[cfg(unix)]
Expand Down Expand Up @@ -768,14 +774,19 @@ mod tests {
None,
)
.await;
assert!(installed.is_empty(), "{installed:?}");
let (mut paths, calls) = tracked_npm_hosted(&common, &installed).await;
// Since #605 the name-keyed resolver reaches the alias and its
// sibling peers on its own. An alias-only set (what an alias-blind
// resolver returns) must still expand to the same copies.
let (mut paths, calls) = tracked_npm_hosted(&common, &HashMap::new()).await;
assert_eq!(calls, vec![vec![alias.clone()]]);
let mut expected = peers;
expected.push(alias);
paths.sort();
expected.sort();
assert_eq!(paths, expected);
let (mut resolved, _) = tracked_npm_hosted(&common, &installed).await;
resolved.sort();
assert_eq!(resolved, expected, "the resolver's own copy set");
}

#[cfg(unix)]
Expand Down
79 changes: 78 additions & 1 deletion crates/socket-patch-cli/tests/e2e_redirect_gem_stale_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,8 @@ async fn gem_hosted_redirect_over_stale_install_warns_loudly() {
);
assert_eq!(code, 0, "human re-scan must succeed:\n{stderr}");
assert!(
stderr.contains("Warning: ") && stderr.contains("was switched to its hosted patch, but a stale"),
stderr.contains("Warning: ")
&& stderr.contains("was switched to its hosted patch, but a stale"),
"human mode must print the stale-install warning on stderr:\n{stderr}"
);
assert!(
Expand Down Expand Up @@ -540,6 +541,82 @@ async fn gem_hosted_stale_purl_is_not_vex_attested_in_the_same_run() {
assert_eq!(env["status"], "error", "envelope: {env}");
}

/// #796: Bundler 4's `bundle install --standalone` installs into
/// `<proj>/bundle/ruby/<abi>/` and writes `bundle/bundler/setup.rb`, but no
/// `.bundle/config` (bundler 4 no longer remembers CLI flags). That tree is
/// what the app loads, so a stale UNPATCHED copy there must trip the guard
/// with the project-local remedy, and the same run's `--vex` must not
/// attest the purl.
#[tokio::test(flavor = "multi_thread")]
async fn gem_hosted_stale_bundler4_standalone_install_warns_and_is_not_attested() {
let server = MockServer::start().await;
mount_api(&server, None).await;
let tmp = tempfile::tempdir().unwrap();
let proj = tmp.path().join("proj");
std::fs::create_dir_all(&proj).unwrap();
write_manifest_pair(&proj);
let home = proj.join("bundle").join("ruby").join("3.3.0");
let gem_dir = home.join("gems").join(format!("{DEP}-{DEP_VERSION}"));
std::fs::create_dir_all(gem_dir.join("lib")).unwrap();
std::fs::write(gem_dir.join("lib").join("stale_probe_gem.rb"), UPSTREAM_LIB).unwrap();
std::fs::create_dir_all(proj.join("bundle").join("bundler")).unwrap();
std::fs::write(
proj.join("bundle").join("bundler").join("setup.rb"),
"require 'rbconfig'\n",
)
.unwrap();
assert!(!proj.join(".bundle").exists(), "bundler 4 writes no config");

let vex_path = proj.join("out.vex.json");
let (code, stdout, stderr) = common::run_with_env(
&proj,
&[
"scan",
"--mode",
"hosted",
"--json",
"--yes",
"--cwd",
proj.to_str().unwrap(),
"--api-url",
&server.uri(),
"--org",
ORG,
"--api-token",
"fake",
"--vex",
vex_path.to_str().unwrap(),
"--vex-product",
"pkg:gem/app@1.0.0",
],
&[],
);
let env = common::parse_json_envelope(&stdout);
assert_eq!(env["redirect"]["redirected"], 1, "envelope: {env}");
let details = stale_warnings(&env);
assert_eq!(
details.len(),
1,
"the stale standalone copy must trip the guard: {env}\nstderr:\n{stderr}"
);
assert!(
details[0].contains(&gem_dir.display().to_string())
&& !details[0].contains("shared gem home"),
"the warning must name the standalone copy as project-local: {}",
details[0]
);
if let Ok(doc) = std::fs::read_to_string(&vex_path) {
assert!(
!doc.contains(PURL),
"a stale standalone copy must never be attested:\n{doc}"
);
}
assert_ne!(
code, 0,
"an all-stale --vex run must fail, not attest.\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
}

/// #483: bundler's cache dir is the `cache_path` setting — `bundle config
/// set --local cache_path vendor/gems` (a committed `.bundle/config`) or a
/// `BUNDLE_CACHE_PATH` export. A fresh checkout whose committed archive at
Expand Down
160 changes: 156 additions & 4 deletions crates/socket-patch-core/src/crawlers/ruby_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
use super::types::{CrawledPackage, CrawlerOptions};
use crate::patch::path_safety;
use crate::utils::fs::{
entry_is_dir, home_dir, is_dir, list_dir_entries, normalize_lexically, run_blocking,
entry_is_dir, home_dir, is_dir, is_file, list_dir_entries, normalize_lexically, run_blocking,
};
use crate::utils::process::{CommandRunner, SystemCommandRunner};

Expand Down Expand Up @@ -314,11 +314,18 @@ impl RubyCrawler {
/// value resolves against the project root, matching
/// `Bundler.bundle_path`; a leading `~` expands against home).
/// Trusted as-is: it is the user's own environment.
/// 3. the `BUNDLE_PATH:` entry of the global config file
/// 3. `<cwd>/bundle` when it holds `bundler/setup.rb` — the tree
/// `bundle install --standalone` writes and the app loads through
/// that script. Bundler 2 also recorded it as `BUNDLE_PATH:
/// "bundle"` (root 1), but bundler 4 no longer remembers CLI flags
/// and writes no config at all, so the marker is the only trace
/// (#796). It counts as an explicit root, like the recorded path it
/// replaces: the `gem env` fallback stays on for the default gems.
/// 4. the `BUNDLE_PATH:` entry of the global config file
/// ([`bundler_global_config_file`], what `bundle config set --global
/// path <dir>` records) — resolved like the env var, and trusted
/// like it: it is the user's own machine state, not project input.
/// 4. `<cwd>/vendor/bundle` — the default deployment/`--path` location.
/// 5. `<cwd>/vendor/bundle` — the default deployment/`--path` location.
///
/// The explicit roots can point anywhere (a machine-wide `BUNDLE_PATH`
/// export must not pull another project's gem store into a non-Ruby
Expand Down Expand Up @@ -359,6 +366,10 @@ impl RubyCrawler {
if let Some(v) = bundle_path_env.filter(|v| !v.is_empty()) {
roots.push(resolve_bundle_path(cwd, Path::new(v), home));
}
let standalone_root = cwd.join("bundle");
if is_file(&standalone_root.join("bundler").join("setup.rb")).await {
roots.push(normalize_lexically(&standalone_root).unwrap_or(standalone_root));
}
// The global config's path (`bundle config set --global path`)
// is the user's own machine state, so it is trusted like the
// env var: no containment guard. Bundler's `Settings#path`
Expand Down Expand Up @@ -851,7 +862,8 @@ fn verify_gem_at_path_sync(path: &Path) -> bool {
/// JSON envelope is the CLI's job, where `--silent`/`--json` gating lives.
pub struct BundleStoreDiscovery {
/// The discovered installed-gem `gems/` stores, in root-precedence
/// order (local config > env > default `vendor/bundle`). Copies found
/// order (local config > env > standalone `bundle/` > default
/// `vendor/bundle`). Copies found
/// under these are the PRIMARY class in apply's multi-copy fan-out;
/// paths outside them are `gem env` fallback-home copies.
pub stores: Vec<PathBuf>,
Expand Down Expand Up @@ -2075,6 +2087,146 @@ mod tests {
);
}

// ── `bundle install --standalone` root (#796) ─────

/// Lay down what `bundle install --standalone` leaves in `<cwd>/bundle`:
/// the scoped `ruby/<abi>/gems/<leaf>` store plus the
/// `bundler/setup.rb` load-path script the app requires. Returns the
/// `gems/` store dir.
async fn stage_standalone_bundle(cwd: &Path, leaf: &str) -> PathBuf {
let root = cwd.join("bundle");
let gems = root.join("ruby").join("3.3.0").join("gems");
tokio::fs::create_dir_all(gems.join(leaf).join("lib"))
.await
.unwrap();
tokio::fs::create_dir_all(root.join("bundler"))
.await
.unwrap();
tokio::fs::write(
root.join("bundler").join("setup.rb"),
"require 'rbconfig'\n$:.unshift File.expand_path(\"#{__dir__}/../#{RUBY_ENGINE}/#{Gem.ruby_api_version}/gems/rack-3.2.7/lib\")\n",
)
.await
.unwrap();
gems
}

/// Bundler 4 no longer remembers CLI flags, so `bundle install
/// --standalone` writes NO `.bundle/config` — the `./bundle` tree is
/// marked only by its `bundler/setup.rb`. Discovery must still find
/// it: it is the tree the app loads (#796).
#[tokio::test]
async fn standalone_bundle_root_discovered_without_config() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("Gemfile"), b"gem \"rack\"\n")
.await
.unwrap();
let gems = stage_standalone_bundle(dir.path(), "rack-3.2.7").await;

let discovery =
RubyCrawler::discover_bundle_stores_with_env(dir.path(), None, None, None, None).await;
assert_eq!(
discovery.stores,
vec![gems],
"standalone store must be discovered"
);
// Same class as bundler 2's recorded `BUNDLE_PATH: "bundle"`: an
// explicit project root, which keeps the `gem env` fallback (the
// default gems live there), not the implicit vendor/bundle one.
assert!(!discovery.default_root_has_stores);
}

/// Without the `bundler/setup.rb` marker a `bundle/` dir is just a
/// project directory (a script folder, a frontend bundle output) and
/// must not be crawled — or patched — as a gem store.
#[tokio::test]
async fn bundle_dir_without_standalone_marker_is_not_a_store() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("Gemfile"), b"gem \"rack\"\n")
.await
.unwrap();
let gems = stage_standalone_bundle(dir.path(), "rack-3.2.7").await;
tokio::fs::remove_file(dir.path().join("bundle").join("bundler").join("setup.rb"))
.await
.unwrap();
assert!(gems.is_dir());

let paths = RubyCrawler::get_vendor_bundle_paths_with_env(dir.path(), None, None).await;
assert!(paths.is_empty(), "no marker, no store; got {paths:?}");
}

/// The standalone probe sits behind the same "looks like a Ruby
/// project" gate as the other explicit roots.
#[tokio::test]
async fn standalone_bundle_root_ignored_without_manifest() {
let dir = tempfile::tempdir().unwrap();
stage_standalone_bundle(dir.path(), "rack-3.2.7").await;

let paths = RubyCrawler::get_vendor_bundle_paths_with_env(dir.path(), None, None).await;
assert!(
paths.is_empty(),
"no Bundler manifest, no store; got {paths:?}"
);
}

/// Bundler 2 records `BUNDLE_PATH: "bundle"` for a standalone install,
/// so the config root and the standalone probe name the same tree: it
/// must be scanned (and patched) once.
#[tokio::test]
async fn standalone_bundle_root_dedups_with_bundler2_config_path() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("Gemfile"), b"gem \"rack\"\n")
.await
.unwrap();
let gems = stage_standalone_bundle(dir.path(), "rack-3.2.7").await;
tokio::fs::create_dir_all(dir.path().join(".bundle"))
.await
.unwrap();
tokio::fs::write(
dir.path().join(".bundle").join("config"),
"---\nBUNDLE_PATH: \"bundle\"\n",
)
.await
.unwrap();

let paths = RubyCrawler::get_vendor_bundle_paths_with_env(dir.path(), None, None).await;
assert_eq!(paths, vec![gems]);
}

/// The standalone tree ranks after the explicit config/env roots and
/// before the implicit `vendor/bundle` default.
#[tokio::test]
async fn standalone_bundle_root_probes_between_env_and_default() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("Gemfile"), b"gem \"rack\"\n")
.await
.unwrap();
let standalone = stage_standalone_bundle(dir.path(), "rack-3.2.7").await;
let env_root = dir.path().join("envstore");
let env_gems = env_root.join("ruby").join("3.3.0").join("gems");
tokio::fs::create_dir_all(env_gems.join("rack-3.2.7").join("lib"))
.await
.unwrap();
let default_gems = dir
.path()
.join("vendor")
.join("bundle")
.join("ruby")
.join("3.3.0")
.join("gems");
tokio::fs::create_dir_all(default_gems.join("rack-3.2.7").join("lib"))
.await
.unwrap();

let paths = RubyCrawler::get_vendor_bundle_paths_with_env(
dir.path(),
Some(env_root.as_os_str()),
None,
)
.await;
assert_eq!(paths, vec![env_gems, standalone, default_gems]);
}

// ── config-sourced root containment (untrusted .bundle/config) ─

/// SECURITY: an ABSOLUTE `BUNDLE_PATH` in the (typically committed,
Expand Down
Loading
Loading