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
215 changes: 209 additions & 6 deletions crates/socket-patch-core/src/crawlers/ruby_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ impl RubyCrawler {
) -> Result<Vec<PathBuf>, std::io::Error> {
Ok(Self::gem_paths_and_discovery(
options,
std::env::var_os("BUNDLE_GEMFILE").as_deref(),
std::env::var_os("BUNDLE_PATH").as_deref(),
ambient_path_system_env().as_deref(),
std::env::var_os("BUNDLE_APP_CONFIG").as_deref(),
Expand All @@ -71,6 +72,7 @@ impl RubyCrawler {
) -> Result<Vec<PathBuf>, std::io::Error> {
Ok(Self::gem_paths_and_discovery(
options,
None,
bundle_path_env,
None,
app_config_env,
Expand All @@ -86,8 +88,10 @@ impl RubyCrawler {
/// from (`None` in global / `--global-prefix` mode, which never probes
/// the Bundler roots), so a caller that needs the discovery's advisories
/// (`skipped_config_path`) does not probe the roots a second time.
#[allow(clippy::too_many_arguments)]
async fn gem_paths_and_discovery(
options: &CrawlerOptions,
gemfile_env: Option<&OsStr>,
bundle_path_env: Option<&OsStr>,
path_system_env: Option<&OsStr>,
app_config_env: Option<&OsStr>,
Expand All @@ -105,6 +109,7 @@ impl RubyCrawler {
// Local mode: probe the Bundler install roots first.
let discovery = Self::discover_bundle_stores_impl(
&options.cwd,
gemfile_env,
bundle_path_env,
path_system_env,
app_config_env,
Expand Down Expand Up @@ -162,6 +167,7 @@ impl RubyCrawler {

let (gem_paths, discovery) = Self::gem_paths_and_discovery(
options,
std::env::var_os("BUNDLE_GEMFILE").as_deref(),
std::env::var_os("BUNDLE_PATH").as_deref(),
ambient_path_system_env().as_deref(),
std::env::var_os("BUNDLE_APP_CONFIG").as_deref(),
Expand Down Expand Up @@ -386,8 +392,10 @@ impl RubyCrawler {
/// [`bundler_ignores_config`]) bundler reads no config file, so the app
/// config's `BUNDLE_PATH` is neither a root nor a refused root, and it
/// shadows nothing. (Callers already drop `global_config` then.)
#[allow(clippy::too_many_arguments)]
async fn discover_bundle_stores_impl(
cwd: &Path,
gemfile_env: Option<&OsStr>,
bundle_path_env: Option<&OsStr>,
path_system_env: Option<&OsStr>,
app_config_env: Option<&OsStr>,
Expand All @@ -396,6 +404,46 @@ impl RubyCrawler {
ignore_config: bool,
) -> BundleStoreDiscovery {
let home = home_env.map(Path::new);
// Every root below is relative to `Bundler.root`, which a
// `BUNDLE_GEMFILE` naming a gemfile in another directory moves
// there (#952): the app config, a relative `BUNDLE_PATH`, the
// standalone and default stores all follow it. Only for a Ruby
// project, like every explicit root: a machine-wide
// `BUNDLE_GEMFILE` export must not pull another project's stores
// into a non-Ruby scan.
let has_manifest = Self::has_bundler_manifest(cwd).await;
let bundler_root = if has_manifest {
bundler_moved_root(
cwd,
BundlerEnv {
gemfile: gemfile_env,
lockfile: None,
app_config: app_config_env,
ignore_config,
global_config,
},
home,
)
.await
} else {
None
};
// The app config's `BUNDLE_PATH` stays contained in the project
// even when it resolves against a moved root inside it
// (`gemfiles/` recording `../vendor/bundle`). A root the user's
// own environment moved outside the project bounds it instead.
let containment = match &bundler_root {
Some(root)
if !std::path::absolute(cwd)
.ok()
.and_then(|p| normalize_lexically(&p))
.is_some_and(|p| root.starts_with(p)) =>
{
root.clone()
}
_ => cwd.to_path_buf(),
};
Comment thread
mikolalysenko marked this conversation as resolved.
let cwd = bundler_root.as_deref().unwrap_or(cwd);
let default_root = cwd.join("vendor").join("bundle");
let default_root = normalize_lexically(&default_root).unwrap_or(default_root);

Expand All @@ -407,7 +455,7 @@ impl RubyCrawler {
// copy lives (#915). Gated like the explicit roots: config only
// counts for a Ruby project. (A recorded local path never coexists
// with it: [`parse_bundle_config_path`] already drops it.)
let path_tier = if Self::has_bundler_manifest(cwd).await {
let path_tier = if has_manifest {
Self::bundler_path_tier(
cwd,
bundle_path_env,
Expand All @@ -425,11 +473,17 @@ impl RubyCrawler {
let mut roots: Vec<PathBuf> = Vec::new();
let mut skipped_config_path = None;
let mut skipped_config_root = None;
if Self::has_bundler_manifest(cwd).await {
if has_manifest {
if let Some(value) =
Self::app_config_bundle_path(cwd, app_config_env, ignore_config, home).await
{
match resolve_config_bundle_path(cwd, &value, home) {
let contained = match &bundler_root {
None => resolve_config_bundle_path(cwd, &value, home),
Some(_) => resolve_bundle_path(cwd, Path::new(&value), home)
.to_str()
.and_then(|moved| resolve_config_bundle_path(&containment, moved, home)),
};
match contained {
Some(root) => roots.push(root),
// Refused by the containment guard. Recorded — not
// printed: the crawler has no --silent/--json context,
Expand Down Expand Up @@ -522,6 +576,7 @@ impl RubyCrawler {
default_root_has_stores,
skipped_config_path,
skipped_config_root,
bundler_root,
}
}

Expand All @@ -534,6 +589,7 @@ impl RubyCrawler {
pub async fn discover_bundle_stores(cwd: &Path) -> BundleStoreDiscovery {
Self::discover_bundle_stores_impl(
cwd,
std::env::var_os("BUNDLE_GEMFILE").as_deref(),
std::env::var_os("BUNDLE_PATH").as_deref(),
ambient_path_system_env().as_deref(),
std::env::var_os("BUNDLE_APP_CONFIG").as_deref(),
Expand All @@ -556,6 +612,7 @@ impl RubyCrawler {
) -> BundleStoreDiscovery {
Self::discover_bundle_stores_impl(
cwd,
None,
bundle_path_env,
None,
app_config_env,
Expand Down Expand Up @@ -583,6 +640,7 @@ impl RubyCrawler {
);
Self::discover_bundle_stores_impl(
cwd,
None,
bundle_path_env,
path_system_env,
None,
Expand All @@ -609,6 +667,7 @@ impl RubyCrawler {
pub async fn verification_only_gem_paths(&self, options: &CrawlerOptions) -> Vec<PathBuf> {
self.verification_only_gem_paths_with_env(
options,
std::env::var_os("BUNDLE_GEMFILE").as_deref(),
std::env::var_os("BUNDLE_PATH").as_deref(),
std::env::var_os("BUNDLE_APP_CONFIG").as_deref(),
ambient_home().as_deref(),
Expand All @@ -621,9 +680,11 @@ impl RubyCrawler {
/// [`Self::verification_only_gem_paths`] with the environment passed
/// explicitly (the hermetic test seam, like
/// [`Self::get_gem_paths_with_env`]).
#[allow(clippy::too_many_arguments)]
pub async fn verification_only_gem_paths_with_env(
&self,
options: &CrawlerOptions,
gemfile_env: Option<&OsStr>,
bundle_path_env: Option<&OsStr>,
app_config_env: Option<&OsStr>,
home_env: Option<&OsStr>,
Expand All @@ -639,6 +700,7 @@ impl RubyCrawler {
// config root this reads, so the env flag is not needed here.
let discovery = Self::discover_bundle_stores_impl(
&options.cwd,
gemfile_env,
bundle_path_env,
None,
app_config_env,
Expand Down Expand Up @@ -731,7 +793,7 @@ impl RubyCrawler {
let ignore_config = bundler_ignores_config();
!bundler_sets_explicit_path(BundlerPathTiers {
local: read_app_config(
cwd,
discovery.bundler_root.as_deref().unwrap_or(cwd),
std::env::var_os("BUNDLE_APP_CONFIG").as_deref(),
ignore_config,
)
Expand Down Expand Up @@ -1416,6 +1478,10 @@ pub struct BundleStoreDiscovery {
/// normalized). Never a write target: only
/// [`RubyCrawler::verification_only_gem_paths`] probes it, read-only.
pub skipped_config_root: Option<PathBuf>,
/// `Bundler.root` when the configured `BUNDLE_GEMFILE` moves it out of
/// the project root ([`bundler_moved_root`]); every root above was
/// probed under it. `None` when Bundler's root is the project root.
pub bundler_root: Option<PathBuf>,
}

/// The stable warning `(code, detail)` for a config-sourced `BUNDLE_PATH`
Expand Down Expand Up @@ -1810,6 +1876,58 @@ pub async fn bundler_loaded_manifest_with_env(
)
}

/// `Bundler.root` when the configured `BUNDLE_GEMFILE` moves it out of
/// `root`, else `None`. Bundler's root is the directory of the gemfile it
/// loads (`SharedHelpers#root`), so a gemfile in another directory (the
/// Appraisal layout, `BUNDLE_GEMFILE=gemfiles/rails_7.gemfile`) makes
/// Bundler read that directory's `.bundle/config` and resolve a relative
/// bundle path against it (#952). Only an existing file moves the root:
/// Bundler refuses a missing gemfile, so nothing installs from there.
///
/// A leading `~` expands against `home`, as in bundler's `expand_path`.
///
/// The env and global config values are the user's own machine state; a
/// value from the app config is project input, so it only moves the root
/// to a directory inside `root`, through symlinks too: the moved root's
/// stores become apply write targets.
pub(crate) async fn bundler_moved_root(
root: &Path,
env: BundlerEnv<'_>,
home: Option<&Path>,
) -> Option<PathBuf> {
use crate::formats::gem::manifest::{GemfileSetting, LoadedManifest};
let LoadedManifest::Unsupported { value, by } =
bundler_loaded_manifest_with_env(root, env).await
else {
return None;
};
let absolute = |p: &Path| {
std::path::absolute(p)
.ok()
.and_then(|p| normalize_lexically(&p))
};
let gemfile = absolute(&root.join(expand_tilde(Path::new(&value), home)))?;
let is_file = tokio::fs::metadata(&gemfile)
.await
.is_ok_and(|m| m.is_file());
let dir = gemfile.parent().filter(|_| is_file)?;
let here = absolute(root)?;
if dir == here || same_file::is_same_file(dir, &here).unwrap_or(false) {
return None;
}
if by == GemfileSetting::AppConfig {
let canonical = |p: &Path| std::fs::canonicalize(p).ok();
let contained = match (canonical(dir), canonical(&here)) {
(Some(real_dir), Some(real_here)) => real_dir.starts_with(real_here),
_ => false,
};
if !dir.starts_with(&here) || !contained {
return None;
}
}
Some(dir.to_path_buf())
}

/// The Bundler mirror setting that captures one of the patch-registry
/// `sources` (see [`crate::formats::gem::mirror`]), described for the
/// refusal's detail. Reads the app config (honoring `BUNDLE_APP_CONFIG`
Expand Down Expand Up @@ -2312,6 +2430,88 @@ async fn bundle_config_dir_reading(
mod tests {
use super::*;

/// #952: only an existing gemfile in another directory moves
/// `Bundler.root`; one in the project root, a missing file, or an app
/// config value (project input) naming a directory outside the
/// project leaves discovery at the project root.
#[tokio::test]
async fn moved_root_follows_an_existing_gemfile_in_another_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("app");
let outside = tmp.path().join("elsewhere");
for dir in [root.join("gemfiles"), outside.clone()] {
std::fs::create_dir_all(dir).unwrap();
}
for file in [
root.join("Gemfile"),
root.join("Gemfile.next"),
root.join("gemfiles/alt.gemfile"),
outside.join("Gemfile"),
] {
std::fs::write(file, "gem \"foo\"\n").unwrap();
}
// `~` is the temp dir, so `~/app/...` names the project's files.
let home = tmp.path().to_path_buf();
let moved = |gemfile: Option<&'static str>| {
let root = root.clone();
let home = home.clone();
async move {
bundler_moved_root(
&root,
BundlerEnv {
gemfile: gemfile.map(OsStr::new),
..BundlerEnv::default()
},
Some(&home),
)
.await
}
};
let gemfiles = std::path::absolute(root.join("gemfiles")).unwrap();
assert_eq!(
moved(Some("gemfiles/alt.gemfile")).await,
Some(gemfiles.clone())
);
assert_eq!(
moved(Some("./gemfiles/../gemfiles/alt.gemfile")).await,
Some(gemfiles.clone())
);
assert_eq!(moved(None).await, None);
assert_eq!(moved(Some("Gemfile")).await, None);
assert_eq!(moved(Some("Gemfile.next")).await, None);
assert_eq!(moved(Some("gemfiles/missing.gemfile")).await, None);
assert_eq!(moved(Some("gemfiles")).await, None, "a directory");
assert_eq!(
moved(Some("~/app/gemfiles/alt.gemfile")).await,
Some(gemfiles.clone())
);
// The user's own environment may point anywhere.
assert_eq!(
moved(Some("../elsewhere/Gemfile")).await,
Some(std::path::absolute(&outside).unwrap())
);

// A committed app config may move the root inside the project
// only: the moved root's stores become apply write targets.
std::fs::create_dir_all(root.join(".bundle")).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(&outside, root.join("linked")).unwrap();
for (value, expected) in [
("gemfiles/alt.gemfile", Some(gemfiles.clone())),
("../elsewhere/Gemfile", None),
// A committed symlink must not carry the root out either.
#[cfg(unix)]
("linked/Gemfile", None),
] {
std::fs::write(
root.join(".bundle/config"),
format!("---\nBUNDLE_GEMFILE: \"{value}\"\n"),
)
.unwrap();
assert_eq!(moved(None).await, expected, "{value}");
}
}

#[tokio::test]
async fn loaded_manifest_reads_the_app_config_file() {
let dir = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -4168,7 +4368,7 @@ mod tests {
discovery.stores
);
let verify = crawler
.verification_only_gem_paths_with_env(&options, None, None, None, None, false)
.verification_only_gem_paths_with_env(&options, None, None, None, None, None, false)
.await;
let gems = normalize_lexically(&gems).unwrap();
assert_eq!(verify, vec![gems.clone()]);
Expand All @@ -4190,6 +4390,7 @@ mod tests {
&options,
None,
None,
None,
Some(outside.path().as_os_str()),
None,
false,
Expand All @@ -4204,6 +4405,7 @@ mod tests {
&options,
None,
None,
None,
Some(outside.path().as_os_str()),
None,
true,
Expand All @@ -4216,6 +4418,7 @@ mod tests {
let verify = crawler
.verification_only_gem_paths_with_env(
&options,
None,
Some(outside.path().as_os_str()),
None,
Some(outside.path().as_os_str()),
Expand All @@ -4231,7 +4434,7 @@ mod tests {
..options
};
assert!(crawler
.verification_only_gem_paths_with_env(&global, None, None, None, None, false)
.verification_only_gem_paths_with_env(&global, None, None, None, None, None, false)
.await
.is_empty());
}
Expand Down
Loading
Loading