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
137 changes: 128 additions & 9 deletions crates/socket-patch-core/src/crawlers/composer_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -401,19 +401,45 @@ fn default_composer_home(var: &impl Fn(&str) -> Option<OsString>) -> Option<Path
}

/// The directories Composer itself uses as its home when `COMPOSER_HOME`
/// is unset, in the order to probe them (`Factory::getHomeDir`):
/// is unset, in the order to probe them (Composer 2's
/// `Factory::getHomeDir`):
///
/// - Windows: `%APPDATA%\Composer` — the only default there; the
/// `~/.composer` probe is kept after it for older layouts.
/// - elsewhere: `~/.composer` when it exists, else the XDG location,
/// `$XDG_CONFIG_HOME/composer` or `~/.config/composer`.
/// - an XDG system (see [`uses_xdg`]): the XDG home,
/// `$XDG_CONFIG_HOME/composer` or else `~/.config/composer`, when it
/// exists, else `~/.composer`. A leftover `~/.composer` from the
/// Composer 1 era (which probed it first) must not hide the real home.
/// A relative `XDG_CONFIG_HOME` names a directory relative to wherever
/// Composer ran, so its XDG home is skipped rather than guessed.
/// - elsewhere: `~/.composer`.
///
/// `~/.config/composer` stays as a last probe on every platform, so a
/// home there is still found when the XDG detection misses it.
///
/// A set-but-empty or relative variable counts as unset: honoring `""`
/// would turn these probes into CWD-relative paths inside the user's
/// project (same rule as `utils::fs::home_dir`).
pub(crate) fn composer_home_candidates(
var: &impl Fn(&str) -> Option<std::ffi::OsString>,
windows: bool,
) -> Vec<PathBuf> {
let xdg = !windows && (var("XDG_CONFIG_HOME").is_some() || uses_xdg());
composer_home_candidates_for(var, windows, xdg)
}

/// Composer's `Factory::useXdg`: any `XDG_*` environment variable is
/// set, or `/etc/xdg` exists.
fn uses_xdg() -> bool {
std::env::vars_os().any(|(key, _)| key.as_encoded_bytes().starts_with(b"XDG_"))
|| is_dir_sync(Path::new("/etc/xdg"))
}

/// [`composer_home_candidates`] with the XDG detection passed in.
fn composer_home_candidates_for(
var: &impl Fn(&str) -> Option<std::ffi::OsString>,
windows: bool,
xdg: bool,
) -> Vec<PathBuf> {
let absolute = |name: &str| {
var(name)
Expand All @@ -427,16 +453,24 @@ pub(crate) fn composer_home_candidates(
candidates.push(app_data.join("Composer"));
}
}
if xdg {
// Composer treats an empty XDG_CONFIG_HOME as unset.
let config = match var("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
Some(_) => absolute("XDG_CONFIG_HOME"),
None => home.as_ref().map(|home| home.join(".config")),
};
if let Some(config) = config {
candidates.push(config.join("composer"));
}
}
if let Some(home) = &home {
candidates.push(home.join(".composer"));
}
if !windows {
if let Some(xdg) = absolute("XDG_CONFIG_HOME") {
candidates.push(xdg.join("composer"));
}
}
if let Some(home) = &home {
candidates.push(home.join(".config").join("composer"));
let config = home.join(".config").join("composer");
if !candidates.contains(&config) {
candidates.push(config);
}
}
candidates
}
Expand Down Expand Up @@ -853,6 +887,91 @@ async fn read_installed_json(vendor_path: &Path) -> Vec<ComposerPackageEntry> {
mod tests {
use super::*;

#[cfg(unix)]
fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<std::ffi::OsString> + 'a {
move |name: &str| {
vars.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| std::ffi::OsString::from(value))
}
}

/// #586: Composer 2 on an XDG system probes the XDG home before
/// `~/.composer`.
#[cfg(unix)]
#[test]
fn test_home_candidates_xdg_system_probes_xdg_home_first() {
assert_eq!(
composer_home_candidates_for(&env(&[("HOME", "/h")]), false, true),
vec![
PathBuf::from("/h/.config/composer"),
PathBuf::from("/h/.composer"),
]
);
assert_eq!(
composer_home_candidates_for(
&env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "/x")]),
false,
true
),
vec![
PathBuf::from("/x/composer"),
PathBuf::from("/h/.composer"),
PathBuf::from("/h/.config/composer"),
]
);
// An empty XDG_CONFIG_HOME counts as unset, as in Composer.
assert_eq!(
composer_home_candidates_for(
&env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "")]),
false,
true
)[0],
PathBuf::from("/h/.config/composer")
);
// A relative one points somewhere under Composer's CWD, not at
// `~/.config`: probe `~/.composer` first instead of guessing.
assert_eq!(
composer_home_candidates_for(
&env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "rel")]),
false,
true
),
vec![
PathBuf::from("/h/.composer"),
PathBuf::from("/h/.config/composer"),
]
);
// With HOME unset nothing is left to probe.
assert!(composer_home_candidates_for(&env(&[("HOME", "")]), false, true).is_empty());
}

/// Off XDG, Composer only uses `~/.composer`; `~/.config/composer`
/// stays as a last probe.
#[cfg(unix)]
#[test]
fn test_home_candidates_non_xdg_system_probes_dot_composer_first() {
assert_eq!(
composer_home_candidates_for(&env(&[("HOME", "/h")]), false, false),
vec![
PathBuf::from("/h/.composer"),
PathBuf::from("/h/.config/composer"),
]
);
assert!(composer_home_candidates_for(&env(&[("HOME", "")]), false, false).is_empty());
}

/// An XDG_CONFIG_HOME in the supplied environment makes it an XDG
/// system even when the process environment has no `XDG_*` variable.
#[cfg(unix)]
#[test]
fn test_home_candidates_supplied_xdg_config_home_counts_as_xdg() {
assert_eq!(
composer_home_candidates(&env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "/x")]), false)[0],
PathBuf::from("/x/composer")
);
}

#[tokio::test]
async fn test_crawl_all_composer() {
let dir = tempfile::tempdir().unwrap();
Expand Down
70 changes: 70 additions & 0 deletions crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ async fn get_vendor_paths_global_composer_cli_nonexistent_home_falls_back() {
let _composer_home = EnvVarGuard::remove("COMPOSER_HOME");
let _home = EnvVarGuard::set("HOME", home.path().as_os_str());
let _path = EnvVarGuard::set("PATH", shim_dir.path().as_os_str());
// `$XDG_CONFIG_HOME/composer` would outrank the HOME candidate here.
let _xdg = EnvVarGuard::remove("XDG_CONFIG_HOME");

let crawler = ComposerCrawler;
let paths = crawler
Expand All @@ -178,3 +180,71 @@ async fn get_vendor_paths_global_composer_cli_nonexistent_home_falls_back() {
"a CLI answer naming a nonexistent home must fall through to HOME/.composer"
);
}

/// #586: on an XDG system Composer 2 looks for its home in the XDG config
/// dir first and only then in `~/.composer` (`Factory::getHomeDir`). A
/// leftover `~/.composer` (Composer 1 era, even an empty one) must not
/// hide the real global install under `~/.config/composer` when the
/// `composer` CLI can't be asked.
#[cfg(not(windows))]
#[tokio::test]
#[serial_test::serial]
async fn get_vendor_paths_global_xdg_home_outranks_stale_dot_composer() {
let home = tempfile::tempdir().unwrap();
tokio::fs::create_dir_all(home.path().join(".composer"))
.await
.unwrap();
let xdg_vendor = home.path().join(".config").join("composer").join("vendor");
tokio::fs::create_dir_all(&xdg_vendor).await.unwrap();
let empty_path = tempfile::tempdir().unwrap();

let _composer_home = EnvVarGuard::remove("COMPOSER_HOME");
let _home = EnvVarGuard::set("HOME", home.path().as_os_str());
let _path = EnvVarGuard::set("PATH", empty_path.path().as_os_str());
let _xdg_config = EnvVarGuard::remove("XDG_CONFIG_HOME");
// Any `XDG_*` variable makes Composer treat the system as XDG.
let _xdg_marker = EnvVarGuard::set("XDG_SESSION_TYPE", std::ffi::OsStr::new("tty"));

let paths = ComposerCrawler
.get_vendor_paths(&global_options_at(home.path()))
.await
.unwrap();

assert_eq!(
paths,
vec![xdg_vendor],
"Composer 2 picks ~/.config/composer over ~/.composer on an XDG system"
);
}

/// The same layout with `XDG_CONFIG_HOME` set: Composer 2 uses
/// `$XDG_CONFIG_HOME/composer`, still ahead of a stale `~/.composer`.
#[cfg(not(windows))]
#[tokio::test]
#[serial_test::serial]
async fn get_vendor_paths_global_xdg_config_home_outranks_stale_dot_composer() {
let home = tempfile::tempdir().unwrap();
tokio::fs::create_dir_all(home.path().join(".composer").join("vendor"))
.await
.unwrap();
let xdg = tempfile::tempdir().unwrap();
let xdg_vendor = xdg.path().join("composer").join("vendor");
tokio::fs::create_dir_all(&xdg_vendor).await.unwrap();
let empty_path = tempfile::tempdir().unwrap();

let _composer_home = EnvVarGuard::remove("COMPOSER_HOME");
let _home = EnvVarGuard::set("HOME", home.path().as_os_str());
let _path = EnvVarGuard::set("PATH", empty_path.path().as_os_str());
let _xdg_config = EnvVarGuard::set("XDG_CONFIG_HOME", xdg.path().as_os_str());

let paths = ComposerCrawler
.get_vendor_paths(&global_options_at(home.path()))
.await
.unwrap();

assert_eq!(
paths,
vec![xdg_vendor],
"Composer 2 picks $XDG_CONFIG_HOME/composer over ~/.composer"
);
}
4 changes: 2 additions & 2 deletions crates/socket-patch-core/tests/global_probe_spawn_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,8 @@ async fn composer_home_falls_back_to_appdata_on_windows() {
assert_eq!(paths, vec![vendor]);
}

/// #438 (2), Unix: with no `composer` to ask and no `~/.composer`,
/// Composer uses `$XDG_CONFIG_HOME/composer`.
/// #438 (2), Unix: with no `composer` to ask, Composer 2 uses
/// `$XDG_CONFIG_HOME/composer` (ahead of `~/.composer` since #586).
#[cfg(unix)]
#[tokio::test]
#[serial]
Expand Down
Loading