8000 Improve stream wrapper support by BrianHenryIE · Pull Request #12396 · composer/composer · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Improve stream wrapper support #12396

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 28 commits into
base: main
Choose a base branch
from

Conversation

BrianHenryIE
Copy link
@BrianHenryIE BrianHenryIE commented May 2, 2025

realpath() always returns false for stream wrappers. This PR changes:

  • Factory and AutoloadGenerator to use the existing Composer\Util\Platform::realpath() function instead of PHP's realpath()
  • Filesystem::isAbsolutePath() to return true for stream wrappers' paths

See:

PhpStan CI check fails for SuggestedPackagesReporter.php which is not related to this PR.

Edit:

There are an additional ~58 uses of regular realpath() in the code (grep -rn src -e "[^t:]realpath(" | grep -v '//' | grep -v "\s*\*" | wc -l), across 30 files (grep -rl src -e "[^t:]realpath(" | wc -l). An okay find & replace regex for PhpStorm is (^((?!//|\*).)*(?<!function |get|::|>))(realpath\(), $1Platform::$3 but it has some false positives, particularly BinaryInstaller's $globalsCode string, and the files may need use Composer\Util\Platform; added.

And in AutoloadGenerator:

// Do not remove double realpath() calls.
// Fixes failing Windows realpath() implementation.
// See https://bugs.php.net/bug.php?id=72738
$basePath = $filesystem->normalizePath(realpath(realpath(Platform::getCwd())));

Maybe the double-realpath call should be moved into Platform::realpath() and the Platform::getCwd() should always return its realpath()?

@BrianHenryIE BrianHenryIE marked this pull request as ready for review May 2, 2025 07:27
return strpos($path, '/') === 0
|| substr($path, 1, 1) === ':'
|| strpos($path, '\\\\') === 0
|| Preg::isMatch($this->streamWrappersRegex, $path);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this should be used inside Platform::realpath for the PR to have any effect?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. This change is for places where a non-absolute path is prepended with the working dir, e.g.

$dir = $filesystem->normalizePath($filesystem->isAbsolutePath($dir) ? $dir : $basePath.'/'.$dir);

which is unnecessary for stream paths.

That said, I do need to look closer at where it is used, e.g.

if ($fs->isAbsolutePath($url)) {
return 'file://' . strtr($url, '\\', '/');
}

@Seldaek
Copy link
Member
Seldaek commented May 12, 2025

Maybe the double-realpath call should be moved into Platform::realpath() and the Platform::getCwd() should always return its realpath()?

Per the linked bug yes maybe we should always realpath n+1 times on windows, for as long as we get a new path, to make sure we resolve junctions.. On linux the double realpath is useless.

As for Platform::getCwd I do not think it should realpath, as you don't always want to realpath it AFAIR.

There are an additional ~58 uses of regular realpath()

I guess those could be modified too for consistency.

@Seldaek Seldaek added this to the 2.9 milestone May 12, 2025
@@ -56,13 +56,19 @@ public static function getCwd(bool $allowEmpty = false): string

/**
* Infallible realpath version that falls back on the given $path if realpath is not working
*
* NB: Do not remove recursive `realpath()` calls. Fixes failing Windows `realpath()` implementation.
* @see https://bugs.php.net/bug.php?id=72738
*/
public static function realpath(string $path): string
{
$realPath = realpath($path);
if ($realPath === false) {
return $path;
Copy link
Author
@BrianHenryIE BrianHenryIE May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return $path;
return file_exists($realPath) || is_dir($realpath);

I was applying the realpath() to Platform::realpath() change across the other cases, and they are sometimes followed by if($realpathResult === false){ } blocks. E.g.

$sourcesRealPath = realpath($sources);
if ($sourcesRealPath === false) {
throw new \RuntimeException('Could not realpath() the source directory "'.$sources.'"');
}

I'm thinking Platform::realpath() should return false if the path is incorrect, which is inherent in a realpath('/does/not/exist') call, and which the stream wrapper should handle correctly.

(and revert 198a7ea)

I need to look at the original reason Platform::realpath() was introduced: nicolas-grekas@3d1fb9d

Any suggestion on writing tests on this? I often use antecedent/patchwork which allows redefining built in PHP functions. Although I see composer/composer is using PHPUnit in an unconventional way.

I have applied Platform::realpath() wherever it was not followed by a false check: d1081a3

8000
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking Platform::realpath() should return false if the path is incorrect, which is inherent in a realpath('/does/not/exist') call, and which the stream wrapper should handle correctly.

Yes, this complicates Platform::realpath() usage.. Ideally we avoid returning false, because checking for it everywhere is a pain. I can see with stream wrappers it's probably more likely to return false, but what are conditions where they would do that? Can we recover from this or should we just throw?

The fallback to return $path; that we currently have in Platform::realpath is because in theory realpath shouldn't fail when the path exists, and we don't call it if the path doesn't exist AFAIK. I have never been able to conclusively say what might trigger a false otherwise, so I put this fallback in place and nobody ever complained so either it isn't used or it works 🤷🏻‍♂️ .

I'd probably be fine throwing in case false is returned, and then we see what happens in the real world, if it causes issues we can still revert to falling back for non-stream-wrapper paths I guess to restore the previous behavior.

Stream wrapper usage is definitely very fringe, so I am a bit worried of degrading the overall experience just to fix this.

Any suggestion on writing tests on this? I often use antecedent/patchwork which allows redefining built in PHP functions. Although I see composer/composer is using PHPUnit in an unconventional way.

You should be able to use patchwork as it requires php 7.1+, so just give it a shot I guess..

if (false === $resolvedPath) {
continue;
}
$resolvedPath = Platform::realpath($installPath . '/' . $updir);
Copy link
Member
@Seldaek Seldaek May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a special case where we aren't checking file_exists before, and thus using the false return value as indication that the path doesn't exist and can be skipped.

So I'd probably add a file_exists check before, assuming realpath is changed to throw on failure. Or try/catch to be safer perhaps, because I suppose even if the file exists it might still throw in some weird conditions.

@@ -142,7 +143,7 @@ public function compile(string $pharFile = 'composer.phar'): void
__DIR__ . '/../../vendor/symfony/console/Resources/bin/hiddeninput.exe',
__DIR__ . '/../../vendor/symfony/console/Resources/completion.bash',
] as $file) {
$extraFiles[$file] = realpath($file);
$extraFiles[$file] = Platform::realpath($file);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one should be moved below the file_exists check that happens after realpath..

Copy link
Author
@BrianHenryIE BrianHenryIE Jul 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RuntimeException thrown in Platform::realpath() doesn't change much:

$extraFil
8000
es[$file] = realpath($file);
if (!file_exists($file)) {
    throw new \RuntimeException('Extra file listed is missing from the filesystem: '.$file);
}

can be replaced with:

/** @throws RuntimeException */
$extraFiles[$file] = Platform::realpath($file);

I'm not sure what the message should be:

Path does not exist: ...

or

Could not realpath(): ...

or other.

It can easily be specific to each place ::realpath() is called, but I think most developers will figure out what's happening from the backtrace rather than the message (assuming it's thrown all the way!).

@@ -199,7 +199,7 @@ public function doRun(InputInterface $input, OutputInterface $output): int
&& $input->hasParameterOption('-h', true) === false
) {
$dir = dirname(Platform::getCwd(true));
$home = realpath(Platform::getEnv('HOME') ?: Platform::getEnv('USERPROFILE') ?: '/');
$home = Platform::realpath(Platform::getEnv('HOME') ?: Platform::getEnv('USERPROFILE') ?: '/');
Copy link
Member
@Seldaek Seldaek May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one too I'd probably try/catch and use $home = Platform::realpath('/') as fallback. Because otherwise if HOME points to an invalid dir somehow this will blow up in bad ways for people, and this is a fairly hot code path so I'd rather not risk major issues.

*/
public static function realpath(string $path): string
{
$realPath = realpath($path);
6D40 if ($realPath === false) {
return $path;
}
if ($realPath !== $path) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if ($realPath !== $path) {
if (self::isWindows() && $realPath !== $path) {

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about this one. On one hand, it documents the reality better, but it isn't necessary since it will always evaluate to false on non-Windows platforms (as I understand).

@BrianHenryIE BrianHenryIE marked this pull request as draft June 12, 2025 04:40
@BrianHenryIE
Copy link
Author
BrianHenryIE commented Jun 12, 2025

assuming realpath is changed to throw on failure

This is the main change since I last pushed.

Hey. I've converted this to draft as I continue to work on it. The plan is to go through each change and leave a PR comment explaining that/why a try/catch has been added or is unneeded, which you can mark resolved. I haven't thoroughly reviewed every find+replace myself yet (just the easy ones).

Testing with Patchwork didn't work, ironically streamwrapper related, and when I went to open a PR to Patchwork itself I was getting different/new/further errors and got overwhelmed at what potentially lay ahead. I'll mull it over.

try {
$io->write('<info>path</info> : ' . Platform::realpath($path));
} catch (\RuntimeException $exception) {
$io->write('<info>path</info> : ');
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This preserves current behaviour but isn't great IMO.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I'd say that is probably ok to expect the realpath to work here and remove the catch.

@@ -129,7 +129,7 @@ public function write(bool $devMode, InstallationManager $installationManager)
$repoDir = dirname($this->file->getPath());
$this->filesystem->ensureDirectoryExists($repoDir);

$repoDir = $this->filesystem->normalizePath(realpath($repoDir));
$repoDir = $this->filesystem->normalizePath(Platform::realpath($repoDir));
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, if realpath() had returned false, a TypeError would have been thrown in the call to Filesystem::normalizePath()

Replacing the system realpath() call with the Platform::realpath() call now throws an exception, and gives a clearer message.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I also think this is an improvement.. realpath should in theory not fail, so sounds ok to me to fail hard there.

@BrianHenryIE
Copy link
Author
BrianHenryIE commented Jul 4, 2025

Hey. Still lots to do, but I think in many cases the new throwing of the RuntimeException is almost an improvement on the previous behaviour – an explicit exception with a message rather than a TypeError when false is passed to a subsequent typed method.

Plenty more to do, but if you agree or disagree with that, it would help me know how much more I need to work on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants
0