diff --git a/crates/socket-patch-core/src/crawlers/composer_crawler.rs b/crates/socket-patch-core/src/crawlers/composer_crawler.rs index 4e9e2784c..8aecb05c8 100644 --- a/crates/socket-patch-core/src/crawlers/composer_crawler.rs +++ b/crates/socket-patch-core/src/crawlers/composer_crawler.rs @@ -401,12 +401,21 @@ fn default_composer_home(var: &impl Fn(&str) -> Option) -> Option Option) -> Option Option, windows: bool, +) -> Vec { + 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, + windows: bool, + xdg: bool, ) -> Vec { let absolute = |name: &str| { var(name) @@ -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 } @@ -853,6 +887,91 @@ async fn read_installed_json(vendor_path: &Path) -> Vec { mod tests { use super::*; + #[cfg(unix)] + fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + '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(); diff --git a/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs b/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs index 997175812..db21a33fd 100644 --- a/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs +++ b/crates/socket-patch-core/tests/covgap_crawlers_composer_crawler.rs @@ -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 @@ -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" + ); +} diff --git a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs index 4cd9f219d..29e251336 100644 --- a/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs +++ b/crates/socket-patch-core/tests/global_probe_spawn_e2e.rs @@ -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]