diff --git a/.github/wiki b/.github/wiki index 31597f3b68..d9b4ec9214 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 31597f3b686ee592b42ccc6d073cf89f26c5f072 +Subproject commit d9b4ec9214202b274067d3e6deb18770447e97d1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d8466270e..8790fdee14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Improve self-update global/local scope detection by normalizing Composer home candidates with realpath fallback handling and `XDG_CONFIG_HOME` support, to avoid global installs accidentally running as local updates in symlinked or alternate Composer home environments (#335). + ## [1.25.2] - 2026-05-11 ### Fixed diff --git a/src/SelfUpdate/ComposerSelfUpdateScopeResolver.php b/src/SelfUpdate/ComposerSelfUpdateScopeResolver.php index f01855f9c0..978659fb12 100644 --- a/src/SelfUpdate/ComposerSelfUpdateScopeResolver.php +++ b/src/SelfUpdate/ComposerSelfUpdateScopeResolver.php @@ -22,6 +22,9 @@ use FastForward\DevTools\Environment\EnvironmentInterface; use FastForward\DevTools\Path\DevToolsPathResolver; use Symfony\Component\Filesystem\Path; +use Throwable; + +use function Safe\realpath; /** * Detects Composer global DevTools installations from known Composer home paths. @@ -44,10 +47,10 @@ public function __construct( */ public function isGlobalInstallation(): bool { - $packagePath = Path::canonicalize($this->packagePath ?? DevToolsPathResolver::getPackagePath()); + $packagePath = $this->normalizePath($this->packagePath ?? DevToolsPathResolver::getPackagePath()); foreach ($this->getComposerHomeCandidates() as $composerHome) { - $globalPackagePath = Path::canonicalize(Path::join($composerHome, self::PACKAGE_PATH)); + $globalPackagePath = $this->normalizePath(Path::join($composerHome, self::PACKAGE_PATH)); if ($packagePath === $globalPackagePath || str_starts_with( $packagePath, @@ -74,6 +77,12 @@ private function getComposerHomeCandidates(): array $candidates[] = $composerHome; } + $xdgConfigHome = $this->environment->get('XDG_CONFIG_HOME'); + + if (null !== $xdgConfigHome && '' !== $xdgConfigHome) { + $candidates[] = Path::join($xdgConfigHome, 'composer'); + } + $home = $this->environment->get('HOME'); if (null !== $home && '' !== $home) { @@ -90,4 +99,18 @@ private function getComposerHomeCandidates(): array return array_values(array_unique($candidates)); } + + /** + * Safely canonicalizes a path, resolving symlinks when available. + * + * @param string $path + */ + private function normalizePath(string $path): string + { + try { + return Path::canonicalize(realpath($path)); + } catch (Throwable) { + return Path::canonicalize($path); + } + } } diff --git a/tests/SelfUpdate/ComposerSelfUpdateScopeResolverTest.php b/tests/SelfUpdate/ComposerSelfUpdateScopeResolverTest.php index 84c87d5f87..c2f5995061 100644 --- a/tests/SelfUpdate/ComposerSelfUpdateScopeResolverTest.php +++ b/tests/SelfUpdate/ComposerSelfUpdateScopeResolverTest.php @@ -57,6 +57,8 @@ public function isGlobalInstallationWillReturnTrueWhenPackageLivesUnderComposerH ->willReturn(null); $this->environment->get('APPDATA') ->willReturn(null); + $this->environment->get('XDG_CONFIG_HOME') + ->willReturn(null); $resolver = new ComposerSelfUpdateScopeResolver( $this->environment->reveal(), '/home/felipe/.composer/vendor/fast-forward/dev-tools', @@ -77,6 +79,8 @@ public function isGlobalInstallationWillReturnTrueWhenPackageLivesUnderDefaultCo ->willReturn('/Users/felipe'); $this->environment->get('APPDATA') ->willReturn(null); + $this->environment->get('XDG_CONFIG_HOME') + ->willReturn(null); $resolver = new ComposerSelfUpdateScopeResolver( $this->environment->reveal(), '/Users/felipe/Library/Application Support/Composer/vendor/fast-forward/dev-tools', @@ -97,6 +101,8 @@ public function isGlobalInstallationWillReturnFalseWhenPackageLivesUnderProjectV ->willReturn('/home/felipe'); $this->environment->get('APPDATA') ->willReturn(null); + $this->environment->get('XDG_CONFIG_HOME') + ->willReturn(null); $resolver = new ComposerSelfUpdateScopeResolver( $this->environment->reveal(), '/home/felipe/project/vendor/fast-forward/dev-tools', @@ -104,4 +110,26 @@ public function isGlobalInstallationWillReturnFalseWhenPackageLivesUnderProjectV self::assertFalse($resolver->isGlobalInstallation()); } + + /** + * @return void + */ + #[Test] + public function isGlobalInstallationWillReturnTrueWhenPackageLivesUnderXdgComposerHome(): void + { + $this->environment->get('COMPOSER_HOME') + ->willReturn(null); + $this->environment->get('HOME') + ->willReturn(null); + $this->environment->get('APPDATA') + ->willReturn(null); + $this->environment->get('XDG_CONFIG_HOME') + ->willReturn('/tmp/xdg'); + $resolver = new ComposerSelfUpdateScopeResolver( + $this->environment->reveal(), + '/tmp/xdg/composer/vendor/fast-forward/dev-tools', + ); + + self::assertTrue($resolver->isGlobalInstallation()); + } }