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
2 changes: 1 addition & 1 deletion .github/wiki
Submodule wiki updated from 31597f to d9b4ec
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions src/SelfUpdate/ComposerSelfUpdateScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -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);
}
}
}
28 changes: 28 additions & 0 deletions tests/SelfUpdate/ComposerSelfUpdateScopeResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -97,11 +101,35 @@ 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',
);

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());
}
}
Loading