From 11ab5d7c99854aff757f01cd27e709209e72f356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Auswo=CC=88ger?= Date: Wed, 1 Nov 2023 17:49:58 +0100 Subject: [PATCH 01/29] [Process] Pass the commandline as array to `proc_open()` --- Exception/ProcessStartFailedException.php | 45 ++++++++++++++ Messenger/RunProcessContext.php | 4 +- Process.php | 61 +++++++++++++------ .../RunProcessMessageHandlerTest.php | 6 +- Tests/ProcessTest.php | 23 +++++++ 5 files changed, 116 insertions(+), 23 deletions(-) create mode 100644 Exception/ProcessStartFailedException.php diff --git a/Exception/ProcessStartFailedException.php b/Exception/ProcessStartFailedException.php new file mode 100644 index 00000000..9bd5a036 --- /dev/null +++ b/Exception/ProcessStartFailedException.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Process\Exception; + +use Symfony\Component\Process\Process; + +/** + * Exception for processes failed during startup. + */ +class ProcessStartFailedException extends ProcessFailedException +{ + private Process $process; + + public function __construct(Process $process, ?string $message) + { + if ($process->isStarted()) { + throw new InvalidArgumentException('Expected a process that failed during startup, but the given process was started successfully.'); + } + + $error = sprintf('The command "%s" failed.'."\n\nWorking directory: %s\n\nError: %s", + $process->getCommandLine(), + $process->getWorkingDirectory(), + $message ?? 'unknown' + ); + + // Skip parent constructor + RuntimeException::__construct($error); + + $this->process = $process; + } + + public function getProcess(): Process + { + return $this->process; + } +} diff --git a/Messenger/RunProcessContext.php b/Messenger/RunProcessContext.php index b5ade072..5e223040 100644 --- a/Messenger/RunProcessContext.php +++ b/Messenger/RunProcessContext.php @@ -27,7 +27,7 @@ public function __construct( Process $process, ) { $this->exitCode = $process->getExitCode(); - $this->output = $process->isOutputDisabled() ? null : $process->getOutput(); - $this->errorOutput = $process->isOutputDisabled() ? null : $process->getErrorOutput(); + $this->output = !$process->isStarted() || $process->isOutputDisabled() ? null : $process->getOutput(); + $this->errorOutput = !$process->isStarted() || $process->isOutputDisabled() ? null : $process->getErrorOutput(); } } diff --git a/Process.php b/Process.php index 6b73c31d..0b29a646 100644 --- a/Process.php +++ b/Process.php @@ -15,6 +15,7 @@ use Symfony\Component\Process\Exception\LogicException; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Exception\ProcessSignaledException; +use Symfony\Component\Process\Exception\ProcessStartFailedException; use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Exception\RuntimeException; use Symfony\Component\Process\Pipes\UnixPipes; @@ -233,11 +234,11 @@ public function __clone() * * @return int The exit status code * - * @throws RuntimeException When process can't be launched - * @throws RuntimeException When process is already running - * @throws ProcessTimedOutException When process timed out - * @throws ProcessSignaledException When process stopped after receiving signal - * @throws LogicException In case a callback is provided and output has been disabled + * @throws ProcessStartFailedException When process can't be launched + * @throws RuntimeException When process is already running + * @throws ProcessTimedOutException When process timed out + * @throws ProcessSignaledException When process stopped after receiving signal + * @throws LogicException In case a callback is provided and output has been disabled * * @final */ @@ -284,9 +285,9 @@ public function mustRun(callable $callback = null, array $env = []): static * @param callable|null $callback A PHP callback to run whenever there is some * output available on STDOUT or STDERR * - * @throws RuntimeException When process can't be launched - * @throws RuntimeException When process is already running - * @throws LogicException In case a callback is provided and output has been disabled + * @throws ProcessStartFailedException When process can't be launched + * @throws RuntimeException When process is already running + * @throws LogicException In case a callback is provided and output has been disabled */ public function start(callable $callback = null, array $env = []): void { @@ -306,12 +307,7 @@ public function start(callable $callback = null, array $env = []): void $env += '\\' === \DIRECTORY_SEPARATOR ? array_diff_ukey($this->getDefaultEnv(), $env, 'strcasecmp') : $this->getDefaultEnv(); if (\is_array($commandline = $this->commandline)) { - $commandline = implode(' ', array_map($this->escapeArgument(...), $commandline)); - - if ('\\' !== \DIRECTORY_SEPARATOR) { - // exec is mandatory to deal with sending a signal to the process - $commandline = 'exec '.$commandline; - } + $commandline = array_values(array_map(strval(...), $commandline)); } else { $commandline = $this->replacePlaceholders($commandline, $env); } @@ -322,6 +318,11 @@ public function start(callable $callback = null, array $env = []): void // last exit code is output on the fourth pipe and caught to work around --enable-sigchild $descriptors[3] = ['pipe', 'w']; + if (\is_array($commandline)) { + // exec is mandatory to deal with sending a signal to the process + $commandline = 'exec '.$this->buildShellCommandline($commandline); + } + // See https://unix.stackexchange.com/questions/71205/background-process-pipe-input $commandline = '{ ('.$commandline.') <&3 3<&- 3>/dev/null & } 3<&0;'; $commandline .= 'pid=$!; echo $pid >&3; wait $pid 2>/dev/null; code=$?; echo $code >&3; exit $code'; @@ -338,10 +339,20 @@ public function start(callable $callback = null, array $env = []): void throw new RuntimeException(sprintf('The provided cwd "%s" does not exist.', $this->cwd)); } - $process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); + $lastError = null; + set_error_handler(function ($type, $msg) use (&$lastError) { + $lastError = $msg; + + return true; + }); + try { + $process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); + } finally { + restore_error_handler(); + } if (!\is_resource($process)) { - throw new RuntimeException('Unable to launch a new process.'); + throw new ProcessStartFailedException($this, $lastError); } $this->process = $process; $this->status = self::STATUS_STARTED; @@ -366,8 +377,8 @@ public function start(callable $callback = null, array $env = []): void * @param callable|null $callback A PHP callback to run whenever there is some * output available on STDOUT or STDERR * - * @throws RuntimeException When process can't be launched - * @throws RuntimeException When process is already running + * @throws ProcessStartFailedException When process can't be launched + * @throws RuntimeException When process is already running * * @see start() * @@ -943,7 +954,7 @@ public function getLastOutputTime(): ?float */ public function getCommandLine(): string { - return \is_array($this->commandline) ? implode(' ', array_map($this->escapeArgument(...), $this->commandline)) : $this->commandline; + return $this->buildShellCommandline($this->commandline); } /** @@ -1472,8 +1483,18 @@ private function doSignal(int $signal, bool $throwException): bool return true; } - private function prepareWindowsCommandLine(string $cmd, array &$env): string + private function buildShellCommandline(string|array $commandline): string + { + if (\is_string($commandline)) { + return $commandline; + } + + return implode(' ', array_map($this->escapeArgument(...), $commandline)); + } + + private function prepareWindowsCommandLine(string|array $cmd, array &$env): string { + $cmd = $this->buildShellCommandline($cmd); $uid = uniqid('', true); $cmd = preg_replace_callback( '/"(?:( diff --git a/Tests/Messenger/RunProcessMessageHandlerTest.php b/Tests/Messenger/RunProcessMessageHandlerTest.php index 049da77a..e095fa09 100644 --- a/Tests/Messenger/RunProcessMessageHandlerTest.php +++ b/Tests/Messenger/RunProcessMessageHandlerTest.php @@ -33,7 +33,11 @@ public function testRunFailedProcess() (new RunProcessMessageHandler())(new RunProcessMessage(['invalid'])); } catch (RunProcessFailedException $e) { $this->assertSame(['invalid'], $e->context->message->command); - $this->assertSame('\\' === \DIRECTORY_SEPARATOR ? 1 : 127, $e->context->exitCode); + $this->assertContains( + $e->context->exitCode, + [null, '\\' === \DIRECTORY_SEPARATOR ? 1 : 127], + 'Exit code should be 1 on Windows, 127 on other systems, or null', + ); return; } diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 44fb54ee..dfb4fd29 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -16,6 +16,7 @@ use Symfony\Component\Process\Exception\LogicException; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Exception\ProcessSignaledException; +use Symfony\Component\Process\Exception\ProcessStartFailedException; use Symfony\Component\Process\Exception\ProcessTimedOutException; use Symfony\Component\Process\Exception\RuntimeException; use Symfony\Component\Process\InputStream; @@ -66,6 +67,28 @@ public function testInvalidCwd() $cmd->run(); } + /** + * @dataProvider invalidProcessProvider + */ + public function testInvalidCommand(Process $process) + { + try { + $this->assertSame('\\' === \DIRECTORY_SEPARATOR ? 1 : 127, $process->run()); + } catch (ProcessStartFailedException $e) { + // An invalid command might already fail during start since PHP 8.3 for platforms + // supporting posix_spawn(), see https://github.com/php/php-src/issues/12589 + $this->assertStringContainsString('No such file or directory', $e->getMessage()); + } + } + + public function invalidProcessProvider() + { + return [ + [new Process(['invalid'])], + [Process::fromShellCommandline('invalid')], + ]; + } + /** * @group transient-on-windows */ From da3a37850f7d13e2aca31a687eda3ff74a93a38b Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Wed, 1 Nov 2023 09:14:07 +0100 Subject: [PATCH 02/29] [Tests] Streamline --- Tests/ProcessTest.php | 54 +++++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index dfb4fd29..63ad90d9 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -328,11 +328,13 @@ public function testSetInputWhileRunningThrowsAnException() /** * @dataProvider provideInvalidInputValues */ - public function testInvalidInput($value) + public function testInvalidInput(array|object $value) { + $process = $this->getProcess('foo'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('"Symfony\Component\Process\Process::setInput" only accepts strings, Traversable objects or stream resources.'); - $process = $this->getProcess('foo'); + $process->setInput($value); } @@ -347,7 +349,7 @@ public static function provideInvalidInputValues() /** * @dataProvider provideInputValues */ - public function testValidInput($expected, $value) + public function testValidInput(?string $expected, null|float|string $value) { $process = $this->getProcess('foo'); $process->setInput($value); @@ -593,8 +595,10 @@ public function testSuccessfulMustRunHasCorrectExitCode() public function testMustRunThrowsException() { - $this->expectException(ProcessFailedException::class); $process = $this->getProcess('exit 1'); + + $this->expectException(ProcessFailedException::class); + $process->mustRun(); } @@ -972,9 +976,11 @@ public function testExitCodeIsAvailableAfterSignal() public function testSignalProcessNotRunning() { + $process = $this->getProcess('foo'); + $this->expectException(LogicException::class); $this->expectExceptionMessage('Cannot send signal on a non running process.'); - $process = $this->getProcess('foo'); + $process->signal(1); // SIGHUP } @@ -1062,20 +1068,24 @@ public function testDisableOutputDisablesTheOutput() public function testDisableOutputWhileRunningThrowsException() { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Disabling output while the process is running is not possible.'); $p = $this->getProcessForCode('sleep(39);'); $p->start(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Disabling output while the process is running is not possible.'); + $p->disableOutput(); } public function testEnableOutputWhileRunningThrowsException() { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Enabling output while the process is running is not possible.'); $p = $this->getProcessForCode('sleep(40);'); $p->disableOutput(); $p->start(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Enabling output while the process is running is not possible.'); + $p->enableOutput(); } @@ -1091,19 +1101,23 @@ public function testEnableOrDisableOutputAfterRunDoesNotThrowException() public function testDisableOutputWhileIdleTimeoutIsSet() { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Output cannot be disabled while an idle timeout is set.'); $process = $this->getProcess('foo'); $process->setIdleTimeout(1); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Output cannot be disabled while an idle timeout is set.'); + $process->disableOutput(); } public function testSetIdleTimeoutWhileOutputIsDisabled() { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('timeout cannot be set while the output is disabled.'); $process = $this->getProcess('foo'); $process->disableOutput(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('timeout cannot be set while the output is disabled.'); + $process->setIdleTimeout(1); } @@ -1119,11 +1133,13 @@ public function testSetNullIdleTimeoutWhileOutputIsDisabled() */ public function testGetOutputWhileDisabled($fetchMethod) { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Output has been disabled.'); $p = $this->getProcessForCode('sleep(41);'); $p->disableOutput(); $p->start(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Output has been disabled.'); + $p->{$fetchMethod}(); } @@ -1523,17 +1539,21 @@ public function testPreparedCommandWithQuoteInIt() public function testPreparedCommandWithMissingValue() { + $p = Process::fromShellCommandline('echo "${:abc}"'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Command line is missing a value for parameter "abc": echo "${:abc}"'); - $p = Process::fromShellCommandline('echo "${:abc}"'); + $p->run(null, ['bcd' => 'BCD']); } public function testPreparedCommandWithNoValues() { + $p = Process::fromShellCommandline('echo "${:abc}"'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Command line is missing a value for parameter "abc": echo "${:abc}"'); - $p = Process::fromShellCommandline('echo "${:abc}"'); + $p->run(null, []); } From 80826a9791754c93e06a80a348f66837aa5d0d1d Mon Sep 17 00:00:00 2001 From: Dariusz Ruminski Date: Mon, 15 Jan 2024 20:49:54 +0100 Subject: [PATCH 03/29] CS: enable ordered_types.null_adjustment=always_last --- Tests/ProcessTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 63ad90d9..b2a34a21 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -349,7 +349,7 @@ public static function provideInvalidInputValues() /** * @dataProvider provideInputValues */ - public function testValidInput(?string $expected, null|float|string $value) + public function testValidInput(?string $expected, float|string|null $value) { $process = $this->getProcess('foo'); $process->setInput($value); From cebb2aec790b0fba2489af42a6c60933203e6390 Mon Sep 17 00:00:00 2001 From: Dariusz Ruminski Date: Mon, 18 Mar 2024 20:27:13 +0100 Subject: [PATCH 04/29] chore: CS fixes --- Tests/SignalListener.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tests/SignalListener.php b/Tests/SignalListener.php index 618be740..7a351858 100644 --- a/Tests/SignalListener.php +++ b/Tests/SignalListener.php @@ -9,7 +9,10 @@ * file that was distributed with this source code. */ -pcntl_signal(\SIGUSR1, function () { echo 'SIGUSR1'; exit; }); +pcntl_signal(\SIGUSR1, function () { + echo 'SIGUSR1'; + exit; +}); echo 'Caught '; From 14730a20edaff1b8d5ca265b1a8e85aa8ac88993 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Fri, 16 Feb 2024 12:37:04 +0100 Subject: [PATCH 05/29] feat(process): allow to ignore signals when executing a process --- CHANGELOG.md | 5 +++++ Process.php | 34 ++++++++++++++++++++++++++++++++++ Tests/ProcessTest.php | 30 ++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e26819b5..f7b68b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.1 +--- + + * Add `Process::setIgnoredSignals()` to disable signal propagation to the child process + 6.4 --- diff --git a/Process.php b/Process.php index c95afabc..0b593201 100644 --- a/Process.php +++ b/Process.php @@ -77,6 +77,7 @@ class Process implements \IteratorAggregate private bool $tty = false; private bool $pty; private array $options = ['suppress_errors' => true, 'bypass_shell' => true]; + private array $ignoredSignals = []; private WindowsPipes|UnixPipes $processPipes; @@ -346,9 +347,23 @@ public function start(?callable $callback = null, array $env = []): void return true; }); + + $oldMask = []; + + if (\function_exists('pcntl_sigprocmask')) { + // we block signals we want to ignore, as proc_open will use fork / posix_spawn which will copy the signal mask this allow to block + // signals in the child process + pcntl_sigprocmask(\SIG_BLOCK, $this->ignoredSignals, $oldMask); + } + try { $process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); } finally { + if (\function_exists('pcntl_sigprocmask')) { + // we restore the signal mask here to avoid any side effects + pcntl_sigprocmask(\SIG_SETMASK, $oldMask); + } + restore_error_handler(); } @@ -1206,6 +1221,20 @@ public function setOptions(array $options): void } } + /** + * Defines a list of posix signals that will not be propagated to the process. + * + * @param list<\SIG*> $signals + */ + public function setIgnoredSignals(array $signals): void + { + if ($this->isRunning()) { + throw new RuntimeException('Setting ignored signals while the process is running is not possible.'); + } + + $this->ignoredSignals = $signals; + } + /** * Returns whether TTY is supported on the current operating system. */ @@ -1455,6 +1484,11 @@ private function resetProcessData(): void */ private function doSignal(int $signal, bool $throwException): bool { + // Signal seems to be send when sigchild is enable, this allow blocking the signal correctly in this case + if ($this->isSigchildEnabled() && \in_array($signal, $this->ignoredSignals)) { + return false; + } + if (null === $pid = $this->getPid()) { if ($throwException) { throw new LogicException('Cannot send signal on a non running process.'); diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index d027cc50..653fa6d8 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -1663,6 +1663,36 @@ public function testNotTerminableInputPipe() $this->assertFalse($process->isRunning()); } + public function testIgnoringSignal() + { + if (!\function_exists('pcntl_signal')) { + $this->markTestSkipped('pnctl extension is required.'); + } + + $process = $this->getProcess('sleep 10'); + $process->setIgnoredSignals([\SIGTERM]); + + $process->start(); + $process->stop(timeout: 0.2); + + $this->assertNotSame(\SIGTERM, $process->getTermSignal()); + } + + // This test ensure that the previous test is reliable, in case of the sleep command ignoring the SIGTERM signal + public function testNotIgnoringSignal() + { + if (!\function_exists('pcntl_signal')) { + $this->markTestSkipped('pnctl extension is required.'); + } + + $process = $this->getProcess('sleep 10'); + + $process->start(); + $process->stop(timeout: 0.2); + + $this->assertSame(\SIGTERM, $process->getTermSignal()); + } + private function getProcess(string|array $commandline, ?string $cwd = null, ?array $env = null, mixed $input = null, ?int $timeout = 60): Process { if (\is_string($commandline)) { From 8e99b1b15d2975b242fc089382c8f8055a85d996 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Fri, 5 Apr 2024 10:54:29 +0200 Subject: [PATCH 06/29] fix(process): don't call sigprocmask if there is no ignored signals --- Process.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Process.php b/Process.php index 0b593201..656834bd 100644 --- a/Process.php +++ b/Process.php @@ -350,7 +350,7 @@ public function start(?callable $callback = null, array $env = []): void $oldMask = []; - if (\function_exists('pcntl_sigprocmask')) { + if ($this->ignoredSignals && \function_exists('pcntl_sigprocmask')) { // we block signals we want to ignore, as proc_open will use fork / posix_spawn which will copy the signal mask this allow to block // signals in the child process pcntl_sigprocmask(\SIG_BLOCK, $this->ignoredSignals, $oldMask); @@ -359,7 +359,7 @@ public function start(?callable $callback = null, array $env = []): void try { $process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); } finally { - if (\function_exists('pcntl_sigprocmask')) { + if ($this->ignoredSignals && \function_exists('pcntl_sigprocmask')) { // we restore the signal mask here to avoid any side effects pcntl_sigprocmask(\SIG_SETMASK, $oldMask); } From 85a554acd7c28522241faf2e97b9541247a0d3d5 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 18 Apr 2024 09:55:03 +0200 Subject: [PATCH 07/29] Auto-close PRs on subtree-splits --- .gitattributes | 3 +- .github/PULL_REQUEST_TEMPLATE.md | 8 +++++ .github/workflows/check-subtree-split.yml | 37 +++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/check-subtree-split.yml diff --git a/.gitattributes b/.gitattributes index 84c7add0..14c3c359 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,3 @@ /Tests export-ignore /phpunit.xml.dist export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..4689c4da --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/symfony + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/.github/workflows/check-subtree-split.yml b/.github/workflows/check-subtree-split.yml new file mode 100644 index 00000000..16be48ba --- /dev/null +++ b/.github/workflows/check-subtree-split.yml @@ -0,0 +1,37 @@ +name: Check subtree split + +on: + pull_request_target: + +jobs: + close-pull-request: + runs-on: ubuntu-latest + + steps: + - name: Close pull request + uses: actions/github-script@v6 + with: + script: | + if (context.repo.owner === "symfony") { + github.rest.issues.createComment({ + owner: "symfony", + repo: context.repo.repo, + issue_number: context.issue.number, + body: ` + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/symfony + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! + ` + }); + + github.rest.pulls.update({ + owner: "symfony", + repo: context.repo.repo, + pull_number: context.issue.number, + state: "closed" + }); + } From b3da76c30c3f33c21356ceeb631f8958b2b932cd Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Mon, 29 Apr 2024 16:31:15 +0200 Subject: [PATCH 08/29] Remove calls to `TestCase::iniSet()` and calls to deprecated methods of `MockBuilder` --- Tests/ExecutableFinderTest.php | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/Tests/ExecutableFinderTest.php b/Tests/ExecutableFinderTest.php index 5c63cf0f..6d089def 100644 --- a/Tests/ExecutableFinderTest.php +++ b/Tests/ExecutableFinderTest.php @@ -109,12 +109,16 @@ public function testFindWithOpenBaseDir() $this->markTestSkipped('Cannot test when open_basedir is set'); } - $this->iniSet('open_basedir', \dirname(\PHP_BINARY).\PATH_SEPARATOR.'/'); + $initialOpenBaseDir = ini_set('open_basedir', \dirname(\PHP_BINARY).\PATH_SEPARATOR.'/'); - $finder = new ExecutableFinder(); - $result = $finder->find($this->getPhpBinaryName()); + try { + $finder = new ExecutableFinder(); + $result = $finder->find($this->getPhpBinaryName()); - $this->assertSamePath(\PHP_BINARY, $result); + $this->assertSamePath(\PHP_BINARY, $result); + } finally { + ini_set('open_basedir', $initialOpenBaseDir); + } } /** @@ -130,12 +134,17 @@ public function testFindProcessInOpenBasedir() } $this->setPath(''); - $this->iniSet('open_basedir', \PHP_BINARY.\PATH_SEPARATOR.'/'); - $finder = new ExecutableFinder(); - $result = $finder->find($this->getPhpBinaryName(), false); + $initialOpenBaseDir = ini_set('open_basedir', \PHP_BINARY.\PATH_SEPARATOR.'/'); - $this->assertSamePath(\PHP_BINARY, $result); + try { + $finder = new ExecutableFinder(); + $result = $finder->find($this->getPhpBinaryName(), false); + + $this->assertSamePath(\PHP_BINARY, $result); + } finally { + ini_set('open_basedir', $initialOpenBaseDir); + } } public function testFindBatchExecutableOnWindows() From 1393de6f0688c254d8e3bc4933670a6700c2c64b Mon Sep 17 00:00:00 2001 From: Marc Jauvin Date: Tue, 7 May 2024 13:49:07 -0400 Subject: [PATCH 09/29] Return false in isTtySupported() when open_basedir restrictions prevent access to /dev/tty. If open_basedir restrictions are in effect, checking if the file /dev/tty is writable will prevent setting tty mode on the process, and avoid failing to create a Process. --- Process.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Process.php b/Process.php index c804a179..bf2e8e85 100644 --- a/Process.php +++ b/Process.php @@ -1211,7 +1211,7 @@ public static function isTtySupported(): bool { static $isTtySupported; - return $isTtySupported ??= ('/' === \DIRECTORY_SEPARATOR && stream_isatty(\STDOUT)); + return $isTtySupported ??= ('/' === \DIRECTORY_SEPARATOR && stream_isatty(\STDOUT) && @is_writable('/dev/tty')); } /** From deedcb3bb4669cae2148bc920eafd2b16dc7c046 Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 31 May 2024 16:33:22 +0200 Subject: [PATCH 10/29] Revert "minor #54653 Auto-close PRs on subtree-splits (nicolas-grekas)" This reverts commit 2c9352dd91ebaf37b8a3e3c26fd8e1306df2fb73, reversing changes made to 18c3e87f1512be2cc50e90235b144b13bc347258. --- .gitattributes | 3 +- .github/PULL_REQUEST_TEMPLATE.md | 8 ----- .github/workflows/check-subtree-split.yml | 37 ----------------------- 3 files changed, 2 insertions(+), 46 deletions(-) delete mode 100644 .github/PULL_REQUEST_TEMPLATE.md delete mode 100644 .github/workflows/check-subtree-split.yml diff --git a/.gitattributes b/.gitattributes index 14c3c359..84c7add0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ /Tests export-ignore /phpunit.xml.dist export-ignore -/.git* export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 4689c4da..00000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,8 +0,0 @@ -Please do not submit any Pull Requests here. They will be closed. ---- - -Please submit your PR here instead: -https://github.com/symfony/symfony - -This repository is what we call a "subtree split": a read-only subset of that main repository. -We're looking forward to your PR there! diff --git a/.github/workflows/check-subtree-split.yml b/.github/workflows/check-subtree-split.yml deleted file mode 100644 index 16be48ba..00000000 --- a/.github/workflows/check-subtree-split.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Check subtree split - -on: - pull_request_target: - -jobs: - close-pull-request: - runs-on: ubuntu-latest - - steps: - - name: Close pull request - uses: actions/github-script@v6 - with: - script: | - if (context.repo.owner === "symfony") { - github.rest.issues.createComment({ - owner: "symfony", - repo: context.repo.repo, - issue_number: context.issue.number, - body: ` - Thanks for your Pull Request! We love contributions. - - However, you should instead open your PR on the main repository: - https://github.com/symfony/symfony - - This repository is what we call a "subtree split": a read-only subset of that main repository. - We're looking forward to your PR there! - ` - }); - - github.rest.pulls.update({ - owner: "symfony", - repo: context.repo.repo, - pull_number: context.issue.number, - state: "closed" - }); - } From 7f2f542c668ad6c313dc4a5e9c3321f733197eca Mon Sep 17 00:00:00 2001 From: Thibaut THOUEMENT Date: Thu, 18 Jul 2024 11:59:33 +0200 Subject: [PATCH 11/29] Fix ProcessTest - testIgnoringSignal for local --- Tests/ProcessTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 653fa6d8..005b9175 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -1669,7 +1669,7 @@ public function testIgnoringSignal() $this->markTestSkipped('pnctl extension is required.'); } - $process = $this->getProcess('sleep 10'); + $process = $this->getProcess(['sleep', '10']); $process->setIgnoredSignals([\SIGTERM]); $process->start(); @@ -1685,7 +1685,7 @@ public function testNotIgnoringSignal() $this->markTestSkipped('pnctl extension is required.'); } - $process = $this->getProcess('sleep 10'); + $process = $this->getProcess(['sleep', '10']); $process->start(); $process->stop(timeout: 0.2); From 32354f62488486b6efcbcd61a1dc8a619287fd29 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 5 Sep 2024 18:13:22 +0200 Subject: [PATCH 12/29] Don't use is_resource() on non-streams --- Process.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Process.php b/Process.php index a4b0a784..62addf1e 100644 --- a/Process.php +++ b/Process.php @@ -352,7 +352,7 @@ public function start(?callable $callback = null, array $env = []) $this->process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); - if (!\is_resource($this->process)) { + if (!$this->process) { throw new RuntimeException('Unable to launch a new process.'); } $this->status = self::STATUS_STARTED; @@ -1456,8 +1456,9 @@ private function readPipes(bool $blocking, bool $close) private function close(): int { $this->processPipes->close(); - if (\is_resource($this->process)) { + if ($this->process) { proc_close($this->process); + $this->process = null; } $this->exitcode = $this->processInformation['exitcode']; $this->status = self::STATUS_TERMINATED; From 82d962eed80966a41220bd28424ba6a71d3f357d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Auswo=CC=88ger?= Date: Thu, 5 Sep 2024 23:34:25 +0200 Subject: [PATCH 13/29] [Process] Fix backwards compatibility for invalid commands --- Process.php | 5 +++++ Tests/ProcessTest.php | 9 ++------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Process.php b/Process.php index fd3ad875..878296b6 100644 --- a/Process.php +++ b/Process.php @@ -358,6 +358,11 @@ public function start(?callable $callback = null, array $env = []): void try { $process = @proc_open($commandline, $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); + + // Ensure array vs string commands behave the same + if (!$process && \is_array($commandline)) { + $process = @proc_open('exec '.$this->buildShellCommandline($commandline), $descriptors, $this->processPipes->pipes, $this->cwd, $envPairs, $this->options); + } } finally { if ($this->ignoredSignals && \function_exists('pcntl_sigprocmask')) { // we restore the signal mask here to avoid any side effects diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 005b9175..3b0533b7 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -72,13 +72,8 @@ public function testInvalidCwd() */ public function testInvalidCommand(Process $process) { - try { - $this->assertSame('\\' === \DIRECTORY_SEPARATOR ? 1 : 127, $process->run()); - } catch (ProcessStartFailedException $e) { - // An invalid command might already fail during start since PHP 8.3 for platforms - // supporting posix_spawn(), see https://github.com/php/php-src/issues/12589 - $this->assertStringContainsString('No such file or directory', $e->getMessage()); - } + // An invalid command should not fail during start + $this->assertSame('\\' === \DIRECTORY_SEPARATOR ? 1 : 127, $process->run()); } public function invalidProcessProvider() From 6fd79ab51c8342aa1f3395b2e704d3aa6ac68c2c Mon Sep 17 00:00:00 2001 From: Marcus <25648755+M-arcus@users.noreply.github.com> Date: Fri, 6 Sep 2024 14:09:10 +0200 Subject: [PATCH 14/29] PhpSubprocess: Add flag PREG_OFFSET_CAPTURE to preg_match to identify the offset --- PhpSubprocess.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PhpSubprocess.php b/PhpSubprocess.php index a97f8b26..04fd8ea8 100644 --- a/PhpSubprocess.php +++ b/PhpSubprocess.php @@ -106,7 +106,7 @@ private function writeTmpIni(array $iniFiles, string $tmpDir): string throw new RuntimeException('Unable to read ini: '.$file); } // Check and remove directives after HOST and PATH sections - if (preg_match('/^\s*\[(?:PATH|HOST)\s*=/mi', $data, $matches)) { + if (preg_match('/^\s*\[(?:PATH|HOST)\s*=/mi', $data, $matches, \PREG_OFFSET_CAPTURE)) { $data = substr($data, 0, $matches[0][1]); } From 6f16c626e9fcf3fc7ce9c79ef34432adcf792282 Mon Sep 17 00:00:00 2001 From: Jan Walther Date: Tue, 1 Aug 2023 16:37:55 +0200 Subject: [PATCH 15/29] [Process] Fix finding executables independently of open_basedir --- ExecutableFinder.php | 32 +++++++++------------- PhpExecutableFinder.php | 2 +- Tests/ExecutableFinderTest.php | 50 +++++----------------------------- 3 files changed, 21 insertions(+), 63 deletions(-) diff --git a/ExecutableFinder.php b/ExecutableFinder.php index f392c962..a2f184d5 100644 --- a/ExecutableFinder.php +++ b/ExecutableFinder.php @@ -48,25 +48,10 @@ public function addSuffix(string $suffix) */ public function find(string $name, ?string $default = null, array $extraDirs = []) { - if (\ini_get('open_basedir')) { - $searchPath = array_merge(explode(\PATH_SEPARATOR, \ini_get('open_basedir')), $extraDirs); - $dirs = []; - foreach ($searchPath as $path) { - // Silencing against https://bugs.php.net/69240 - if (@is_dir($path)) { - $dirs[] = $path; - } else { - if (basename($path) == $name && @is_executable($path)) { - return $path; - } - } - } - } else { - $dirs = array_merge( - explode(\PATH_SEPARATOR, getenv('PATH') ?: getenv('Path')), - $extraDirs - ); - } + $dirs = array_merge( + explode(\PATH_SEPARATOR, getenv('PATH') ?: getenv('Path')), + $extraDirs + ); $suffixes = ['']; if ('\\' === \DIRECTORY_SEPARATOR) { @@ -78,9 +63,18 @@ public function find(string $name, ?string $default = null, array $extraDirs = [ if (@is_file($file = $dir.\DIRECTORY_SEPARATOR.$name.$suffix) && ('\\' === \DIRECTORY_SEPARATOR || @is_executable($file))) { return $file; } + + if (!@is_dir($dir) && basename($dir) === $name.$suffix && @is_executable($dir)) { + return $dir; + } } } + $command = '\\' === \DIRECTORY_SEPARATOR ? 'where' : 'command -v --'; + if (\function_exists('exec') && ($executablePath = strtok(@exec($command.' '.escapeshellarg($name)), \PHP_EOL)) && is_executable($executablePath)) { + return $executablePath; + } + return $default; } } diff --git a/PhpExecutableFinder.php b/PhpExecutableFinder.php index 45dbcca4..54fe7443 100644 --- a/PhpExecutableFinder.php +++ b/PhpExecutableFinder.php @@ -36,7 +36,7 @@ public function find(bool $includeArgs = true) if ($php = getenv('PHP_BINARY')) { if (!is_executable($php)) { $command = '\\' === \DIRECTORY_SEPARATOR ? 'where' : 'command -v --'; - if ($php = strtok(exec($command.' '.escapeshellarg($php)), \PHP_EOL)) { + if (\function_exists('exec') && $php = strtok(exec($command.' '.escapeshellarg($php)), \PHP_EOL)) { if (!is_executable($php)) { return false; } diff --git a/Tests/ExecutableFinderTest.php b/Tests/ExecutableFinderTest.php index 6d089def..a1b8d6d5 100644 --- a/Tests/ExecutableFinderTest.php +++ b/Tests/ExecutableFinderTest.php @@ -19,20 +19,9 @@ */ class ExecutableFinderTest extends TestCase { - private $path; - protected function tearDown(): void { - if ($this->path) { - // Restore path if it was changed. - putenv('PATH='.$this->path); - } - } - - private function setPath($path) - { - $this->path = getenv('PATH'); - putenv('PATH='.$path); + putenv('PATH='.($_SERVER['PATH'] ?? $_SERVER['Path'])); } public function testFind() @@ -41,7 +30,7 @@ public function testFind() $this->markTestSkipped('Cannot test when open_basedir is set'); } - $this->setPath(\dirname(\PHP_BINARY)); + putenv('PATH='.\dirname(\PHP_BINARY)); $finder = new ExecutableFinder(); $result = $finder->find($this->getPhpBinaryName()); @@ -57,7 +46,7 @@ public function testFindWithDefault() $expected = 'defaultValue'; - $this->setPath(''); + putenv('PATH='); $finder = new ExecutableFinder(); $result = $finder->find('foo', $expected); @@ -71,7 +60,7 @@ public function testFindWithNullAsDefault() $this->markTestSkipped('Cannot test when open_basedir is set'); } - $this->setPath(''); + putenv('PATH='); $finder = new ExecutableFinder(); @@ -86,7 +75,7 @@ public function testFindWithExtraDirs() $this->markTestSkipped('Cannot test when open_basedir is set'); } - $this->setPath(''); + putenv('PATH='); $extraDirs = [\dirname(\PHP_BINARY)]; @@ -109,6 +98,7 @@ public function testFindWithOpenBaseDir() $this->markTestSkipped('Cannot test when open_basedir is set'); } + putenv('PATH='.\dirname(\PHP_BINARY)); $initialOpenBaseDir = ini_set('open_basedir', \dirname(\PHP_BINARY).\PATH_SEPARATOR.'/'); try { @@ -121,32 +111,6 @@ public function testFindWithOpenBaseDir() } } - /** - * @runInSeparateProcess - */ - public function testFindProcessInOpenBasedir() - { - if (\ini_get('open_basedir')) { - $this->markTestSkipped('Cannot test when open_basedir is set'); - } - if ('\\' === \DIRECTORY_SEPARATOR) { - $this->markTestSkipped('Cannot run test on windows'); - } - - $this->setPath(''); - - $initialOpenBaseDir = ini_set('open_basedir', \PHP_BINARY.\PATH_SEPARATOR.'/'); - - try { - $finder = new ExecutableFinder(); - $result = $finder->find($this->getPhpBinaryName(), false); - - $this->assertSamePath(\PHP_BINARY, $result); - } finally { - ini_set('open_basedir', $initialOpenBaseDir); - } - } - public function testFindBatchExecutableOnWindows() { if (\ini_get('open_basedir')) { @@ -163,7 +127,7 @@ public function testFindBatchExecutableOnWindows() $this->assertFalse(is_executable($target)); - $this->setPath(sys_get_temp_dir()); + putenv('PATH='.sys_get_temp_dir()); $finder = new ExecutableFinder(); $result = $finder->find(basename($target), false); From 1b9fa82b5c62cd49da8c9e3952dd8531ada65096 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 17 Sep 2024 14:46:43 +0200 Subject: [PATCH 16/29] [Process] minor fix --- ExecutableFinder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ExecutableFinder.php b/ExecutableFinder.php index a2f184d5..6dc00b7c 100644 --- a/ExecutableFinder.php +++ b/ExecutableFinder.php @@ -71,7 +71,7 @@ public function find(string $name, ?string $default = null, array $extraDirs = [ } $command = '\\' === \DIRECTORY_SEPARATOR ? 'where' : 'command -v --'; - if (\function_exists('exec') && ($executablePath = strtok(@exec($command.' '.escapeshellarg($name)), \PHP_EOL)) && is_executable($executablePath)) { + if (\function_exists('exec') && ($executablePath = strtok(@exec($command.' '.escapeshellarg($name)), \PHP_EOL)) && @is_executable($executablePath)) { return $executablePath; } From 5c03ee6369281177f07f7c68252a280beccba847 Mon Sep 17 00:00:00 2001 From: "Alexander M. Turek" Date: Thu, 19 Sep 2024 23:14:15 +0200 Subject: [PATCH 17/29] Make more data providers static --- Tests/ProcessTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index 3b0533b7..a639f058 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -76,7 +76,7 @@ public function testInvalidCommand(Process $process) $this->assertSame('\\' === \DIRECTORY_SEPARATOR ? 1 : 127, $process->run()); } - public function invalidProcessProvider() + public static function invalidProcessProvider(): array { return [ [new Process(['invalid'])], From 95f3f19d0f8f06e4253c66a0828ddb69f8b8ede4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 23 Sep 2024 11:24:18 +0200 Subject: [PATCH 18/29] Add PR template and auto-close PR on subtree split repositories --- .gitattributes | 3 +-- .github/PULL_REQUEST_TEMPLATE.md | 8 ++++++++ .github/workflows/close-pull-request.yml | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/close-pull-request.yml diff --git a/.gitattributes b/.gitattributes index 84c7add0..14c3c359 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,3 @@ /Tests export-ignore /phpunit.xml.dist export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..4689c4da --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/symfony + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/.github/workflows/close-pull-request.yml b/.github/workflows/close-pull-request.yml new file mode 100644 index 00000000..e55b4781 --- /dev/null +++ b/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/symfony + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! From e2d11b6ca03e3041ca2f53a4da3f16d2f8e45c5a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Tue, 29 Oct 2024 21:56:12 +0100 Subject: [PATCH 19/29] [Process] Fix handling empty path found in the PATH env var with ExecutableFinder --- ExecutableFinder.php | 3 +++ Tests/ExecutableFinderTest.php | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/ExecutableFinder.php b/ExecutableFinder.php index 6dc00b7c..45d91e4a 100644 --- a/ExecutableFinder.php +++ b/ExecutableFinder.php @@ -60,6 +60,9 @@ public function find(string $name, ?string $default = null, array $extraDirs = [ } foreach ($suffixes as $suffix) { foreach ($dirs as $dir) { + if ('' === $dir) { + $dir = '.'; + } if (@is_file($file = $dir.\DIRECTORY_SEPARATOR.$name.$suffix) && ('\\' === \DIRECTORY_SEPARATOR || @is_executable($file))) { return $file; } diff --git a/Tests/ExecutableFinderTest.php b/Tests/ExecutableFinderTest.php index a1b8d6d5..c4876e47 100644 --- a/Tests/ExecutableFinderTest.php +++ b/Tests/ExecutableFinderTest.php @@ -111,6 +111,9 @@ public function testFindWithOpenBaseDir() } } + /** + * @runInSeparateProcess + */ public function testFindBatchExecutableOnWindows() { if (\ini_get('open_basedir')) { @@ -138,6 +141,24 @@ public function testFindBatchExecutableOnWindows() $this->assertSamePath($target.'.BAT', $result); } + /** + * @runInSeparateProcess + */ + public function testEmptyDirInPath() + { + putenv(sprintf('PATH=%s:', \dirname(\PHP_BINARY))); + + touch('executable'); + chmod('executable', 0700); + + $finder = new ExecutableFinder(); + $result = $finder->find('executable'); + + $this->assertSame('./executable', $result); + + unlink('executable'); + } + private function assertSamePath($expected, $tested) { if ('\\' === \DIRECTORY_SEPARATOR) { From 651830b1a3cbae1b58bc63c8ba75c5a735abe522 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 30 Oct 2024 22:56:41 +0100 Subject: [PATCH 20/29] [Process] Properly deal with not-found executables on Windows --- ExecutableFinder.php | 10 ++++++++-- PhpExecutableFinder.php | 16 ++++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/ExecutableFinder.php b/ExecutableFinder.php index 6dc00b7c..d446bb65 100644 --- a/ExecutableFinder.php +++ b/ExecutableFinder.php @@ -70,8 +70,14 @@ public function find(string $name, ?string $default = null, array $extraDirs = [ } } - $command = '\\' === \DIRECTORY_SEPARATOR ? 'where' : 'command -v --'; - if (\function_exists('exec') && ($executablePath = strtok(@exec($command.' '.escapeshellarg($name)), \PHP_EOL)) && @is_executable($executablePath)) { + if (!\function_exists('exec') || \strlen($name) !== strcspn($name, '/'.\DIRECTORY_SEPARATOR)) { + return $default; + } + + $command = '\\' === \DIRECTORY_SEPARATOR ? 'where %s 2> NUL' : 'command -v -- %s'; + $execResult = exec(\sprintf($command, escapeshellarg($name))); + + if (($executablePath = substr($execResult, 0, strpos($execResult, \PHP_EOL) ?: null)) && @is_executable($executablePath)) { return $executablePath; } diff --git a/PhpExecutableFinder.php b/PhpExecutableFinder.php index 54fe7443..b9aff690 100644 --- a/PhpExecutableFinder.php +++ b/PhpExecutableFinder.php @@ -35,12 +35,16 @@ public function find(bool $includeArgs = true) { if ($php = getenv('PHP_BINARY')) { if (!is_executable($php)) { - $command = '\\' === \DIRECTORY_SEPARATOR ? 'where' : 'command -v --'; - if (\function_exists('exec') && $php = strtok(exec($command.' '.escapeshellarg($php)), \PHP_EOL)) { - if (!is_executable($php)) { - return false; - } - } else { + if (!\function_exists('exec') || \strlen($php) !== strcspn($php, '/'.\DIRECTORY_SEPARATOR)) { + return false; + } + + $command = '\\' === \DIRECTORY_SEPARATOR ? 'where %s 2> NUL' : 'command -v -- %s'; + $execResult = exec(\sprintf($command, escapeshellarg($php))); + if (!$php = substr($execResult, 0, strpos($execResult, \PHP_EOL) ?: null)) { + return false; + } + if (!is_executable($php)) { return false; } } From 46c203f382b73a2575d043e49a17073d3c808fad Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Sat, 2 Nov 2024 14:14:29 +0100 Subject: [PATCH 21/29] [Process] Return built-in cmd.exe commands directly in ExecutableFinder --- ExecutableFinder.php | 12 ++++++++++++ Tests/ExecutableFinderTest.php | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/ExecutableFinder.php b/ExecutableFinder.php index 2293595c..1604b6f0 100644 --- a/ExecutableFinder.php +++ b/ExecutableFinder.php @@ -20,6 +20,13 @@ class ExecutableFinder { private $suffixes = ['.exe', '.bat', '.cmd', '.com']; + private const CMD_BUILTINS = [ + 'assoc', 'break', 'call', 'cd', 'chdir', 'cls', 'color', 'copy', 'date', + 'del', 'dir', 'echo', 'endlocal', 'erase', 'exit', 'for', 'ftype', 'goto', + 'help', 'if', 'label', 'md', 'mkdir', 'mklink', 'move', 'path', 'pause', + 'popd', 'prompt', 'pushd', 'rd', 'rem', 'ren', 'rename', 'rmdir', 'set', + 'setlocal', 'shift', 'start', 'time', 'title', 'type', 'ver', 'vol', + ]; /** * Replaces default suffixes of executable. @@ -48,6 +55,11 @@ public function addSuffix(string $suffix) */ public function find(string $name, ?string $default = null, array $extraDirs = []) { + // windows built-in commands that are present in cmd.exe should not be resolved using PATH as they do not exist as exes + if ('\\' === \DIRECTORY_SEPARATOR && \in_array(strtolower($name), self::CMD_BUILTINS, true)) { + return $name; + } + $dirs = array_merge( explode(\PATH_SEPARATOR, getenv('PATH') ?: getenv('Path')), $extraDirs diff --git a/Tests/ExecutableFinderTest.php b/Tests/ExecutableFinderTest.php index c4876e47..adb5556d 100644 --- a/Tests/ExecutableFinderTest.php +++ b/Tests/ExecutableFinderTest.php @@ -159,6 +159,18 @@ public function testEmptyDirInPath() unlink('executable'); } + public function testFindBuiltInCommandOnWindows() + { + if ('\\' !== \DIRECTORY_SEPARATOR) { + $this->markTestSkipped('Can be only tested on windows'); + } + + $finder = new ExecutableFinder(); + $this->assertSame('rmdir', $finder->find('RMDIR')); + $this->assertSame('cd', $finder->find('cd')); + $this->assertSame('move', $finder->find('MoVe')); + } + private function assertSamePath($expected, $tested) { if ('\\' === \DIRECTORY_SEPARATOR) { From b61fb1c70392905d5f5f99824324983124a1dd08 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Mon, 4 Nov 2024 09:44:46 +0100 Subject: [PATCH 22/29] [Process] Improve test cleanup by unlinking in a `finally` block --- Tests/ExecutableFinderTest.php | 36 +++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/Tests/ExecutableFinderTest.php b/Tests/ExecutableFinderTest.php index c4876e47..3995e73a 100644 --- a/Tests/ExecutableFinderTest.php +++ b/Tests/ExecutableFinderTest.php @@ -125,18 +125,20 @@ public function testFindBatchExecutableOnWindows() $target = tempnam(sys_get_temp_dir(), 'example-windows-executable'); - touch($target); - touch($target.'.BAT'); - - $this->assertFalse(is_executable($target)); + try { + touch($target); + touch($target.'.BAT'); - putenv('PATH='.sys_get_temp_dir()); + $this->assertFalse(is_executable($target)); - $finder = new ExecutableFinder(); - $result = $finder->find(basename($target), false); + putenv('PATH='.sys_get_temp_dir()); - unlink($target); - unlink($target.'.BAT'); + $finder = new ExecutableFinder(); + $result = $finder->find(basename($target), false); + } finally { + unlink($target); + unlink($target.'.BAT'); + } $this->assertSamePath($target.'.BAT', $result); } @@ -148,15 +150,17 @@ public function testEmptyDirInPath() { putenv(sprintf('PATH=%s:', \dirname(\PHP_BINARY))); - touch('executable'); - chmod('executable', 0700); - - $finder = new ExecutableFinder(); - $result = $finder->find('executable'); + try { + touch('executable'); + chmod('executable', 0700); - $this->assertSame('./executable', $result); + $finder = new ExecutableFinder(); + $result = $finder->find('executable'); - unlink('executable'); + $this->assertSame('./executable', $result); + } finally { + unlink('executable'); + } } private function assertSamePath($expected, $tested) From a56fe7b6066efd82037aedfbd1c657e3bcce1810 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Mon, 4 Nov 2024 10:27:52 +0100 Subject: [PATCH 23/29] ignore case of built-in cmd.exe commands --- Tests/ExecutableFinderTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tests/ExecutableFinderTest.php b/Tests/ExecutableFinderTest.php index adb5556d..e335e47c 100644 --- a/Tests/ExecutableFinderTest.php +++ b/Tests/ExecutableFinderTest.php @@ -166,9 +166,9 @@ public function testFindBuiltInCommandOnWindows() } $finder = new ExecutableFinder(); - $this->assertSame('rmdir', $finder->find('RMDIR')); - $this->assertSame('cd', $finder->find('cd')); - $this->assertSame('move', $finder->find('MoVe')); + $this->assertSame('rmdir', strtolower($finder->find('RMDIR'))); + $this->assertSame('cd', strtolower($finder->find('cd'))); + $this->assertSame('move', strtolower($finder->find('MoVe'))); } private function assertSamePath($expected, $tested) From 7be8366a553b0ea5ec03d01f68c2214b1ce82e89 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Mon, 4 Nov 2024 10:25:02 +0100 Subject: [PATCH 24/29] fix the directory separator being used --- Tests/ExecutableFinderTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ExecutableFinderTest.php b/Tests/ExecutableFinderTest.php index c4876e47..f85d8c9a 100644 --- a/Tests/ExecutableFinderTest.php +++ b/Tests/ExecutableFinderTest.php @@ -146,7 +146,7 @@ public function testFindBatchExecutableOnWindows() */ public function testEmptyDirInPath() { - putenv(sprintf('PATH=%s:', \dirname(\PHP_BINARY))); + putenv(sprintf('PATH=%s%s', \dirname(\PHP_BINARY), \PATH_SEPARATOR)); touch('executable'); chmod('executable', 0700); From 81e1a0cdac68330b5acec27c427cf59be49c73f7 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Mon, 4 Nov 2024 11:01:19 +0100 Subject: [PATCH 25/29] fix the path separator being used --- Tests/ExecutableFinderTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ExecutableFinderTest.php b/Tests/ExecutableFinderTest.php index 4a6c2c4b..fbeb7f07 100644 --- a/Tests/ExecutableFinderTest.php +++ b/Tests/ExecutableFinderTest.php @@ -157,7 +157,7 @@ public function testEmptyDirInPath() $finder = new ExecutableFinder(); $result = $finder->find('executable'); - $this->assertSame('./executable', $result); + $this->assertSame(sprintf('.%sexecutable', \PATH_SEPARATOR), $result); } finally { unlink('executable'); } From 72baf6b0591f07b051450bdf2608f93fb5c0a6e5 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Mon, 4 Nov 2024 11:14:40 +0100 Subject: [PATCH 26/29] fix the constant being used --- Tests/ExecutableFinderTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ExecutableFinderTest.php b/Tests/ExecutableFinderTest.php index fbeb7f07..4aadd9b2 100644 --- a/Tests/ExecutableFinderTest.php +++ b/Tests/ExecutableFinderTest.php @@ -157,7 +157,7 @@ public function testEmptyDirInPath() $finder = new ExecutableFinder(); $result = $finder->find('executable'); - $this->assertSame(sprintf('.%sexecutable', \PATH_SEPARATOR), $result); + $this->assertSame(sprintf('.%sexecutable', \DIRECTORY_SEPARATOR), $result); } finally { unlink('executable'); } From d94dda5a49f8e43523d6966ab705a754001d42fe Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 4 Nov 2024 11:43:26 +0100 Subject: [PATCH 27/29] [Process] Fix escaping /X arguments on Windows --- Process.php | 2 +- Tests/ProcessTest.php | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Process.php b/Process.php index 62addf1e..b8012dda 100644 --- a/Process.php +++ b/Process.php @@ -1638,7 +1638,7 @@ private function escapeArgument(?string $argument): string if (str_contains($argument, "\0")) { $argument = str_replace("\0", '?', $argument); } - if (!preg_match('/[\/()%!^"<>&|\s]/', $argument)) { + if (!preg_match('/[()%!^"<>&|\s]/', $argument)) { return $argument; } $argument = preg_replace('/(\\\\+)$/', '$1$1', $argument); diff --git a/Tests/ProcessTest.php b/Tests/ProcessTest.php index a2e370de..e4d92874 100644 --- a/Tests/ProcessTest.php +++ b/Tests/ProcessTest.php @@ -1424,7 +1424,12 @@ public function testGetCommandLine() { $p = new Process(['/usr/bin/php']); - $expected = '\\' === \DIRECTORY_SEPARATOR ? '"/usr/bin/php"' : "'/usr/bin/php'"; + $expected = '\\' === \DIRECTORY_SEPARATOR ? '/usr/bin/php' : "'/usr/bin/php'"; + $this->assertSame($expected, $p->getCommandLine()); + + $p = new Process(['cd', '/d']); + + $expected = '\\' === \DIRECTORY_SEPARATOR ? 'cd /d' : "'cd' '/d'"; $this->assertSame($expected, $p->getCommandLine()); } From 05c2ccc705cb0336becfdc10f6dd67896d9ba91a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 28 Oct 2024 12:35:32 +0100 Subject: [PATCH 28/29] [Process] Use %PATH% before %CD% to load the shell on Windows --- ExecutableFinder.php | 14 ++++++++------ PhpExecutableFinder.php | 15 ++------------- Process.php | 9 ++++++++- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/ExecutableFinder.php b/ExecutableFinder.php index 1604b6f0..89edd22f 100644 --- a/ExecutableFinder.php +++ b/ExecutableFinder.php @@ -19,7 +19,6 @@ */ class ExecutableFinder { - private $suffixes = ['.exe', '.bat', '.cmd', '.com']; private const CMD_BUILTINS = [ 'assoc', 'break', 'call', 'cd', 'chdir', 'cls', 'color', 'copy', 'date', 'del', 'dir', 'echo', 'endlocal', 'erase', 'exit', 'for', 'ftype', 'goto', @@ -28,6 +27,8 @@ class ExecutableFinder 'setlocal', 'shift', 'start', 'time', 'title', 'type', 'ver', 'vol', ]; + private $suffixes = []; + /** * Replaces default suffixes of executable. */ @@ -65,11 +66,13 @@ public function find(string $name, ?string $default = null, array $extraDirs = [ $extraDirs ); - $suffixes = ['']; + $suffixes = []; if ('\\' === \DIRECTORY_SEPARATOR) { $pathExt = getenv('PATHEXT'); - $suffixes = array_merge($pathExt ? explode(\PATH_SEPARATOR, $pathExt) : $this->suffixes, $suffixes); + $suffixes = $this->suffixes; + $suffixes = array_merge($suffixes, $pathExt ? explode(\PATH_SEPARATOR, $pathExt) : ['.exe', '.bat', '.cmd', '.com']); } + $suffixes = '' !== pathinfo($name, PATHINFO_EXTENSION) ? array_merge([''], $suffixes) : array_merge($suffixes, ['']); foreach ($suffixes as $suffix) { foreach ($dirs as $dir) { if ('' === $dir) { @@ -85,12 +88,11 @@ public function find(string $name, ?string $default = null, array $extraDirs = [ } } - if (!\function_exists('exec') || \strlen($name) !== strcspn($name, '/'.\DIRECTORY_SEPARATOR)) { + if ('\\' === \DIRECTORY_SEPARATOR || !\function_exists('exec') || \strlen($name) !== strcspn($name, '/'.\DIRECTORY_SEPARATOR)) { return $default; } - $command = '\\' === \DIRECTORY_SEPARATOR ? 'where %s 2> NUL' : 'command -v -- %s'; - $execResult = exec(\sprintf($command, escapeshellarg($name))); + $execResult = exec('command -v -- '.escapeshellarg($name)); if (($executablePath = substr($execResult, 0, strpos($execResult, \PHP_EOL) ?: null)) && @is_executable($executablePath)) { return $executablePath; diff --git a/PhpExecutableFinder.php b/PhpExecutableFinder.php index b9aff690..c3a9680d 100644 --- a/PhpExecutableFinder.php +++ b/PhpExecutableFinder.php @@ -34,19 +34,8 @@ public function __construct() public function find(bool $includeArgs = true) { if ($php = getenv('PHP_BINARY')) { - if (!is_executable($php)) { - if (!\function_exists('exec') || \strlen($php) !== strcspn($php, '/'.\DIRECTORY_SEPARATOR)) { - return false; - } - - $command = '\\' === \DIRECTORY_SEPARATOR ? 'where %s 2> NUL' : 'command -v -- %s'; - $execResult = exec(\sprintf($command, escapeshellarg($php))); - if (!$php = substr($execResult, 0, strpos($execResult, \PHP_EOL) ?: null)) { - return false; - } - if (!is_executable($php)) { - return false; - } + if (!is_executable($php) && !$php = $this->executableFinder->find($php)) { + return false; } if (@is_dir($php)) { diff --git a/Process.php b/Process.php index 62addf1e..0f3457f3 100644 --- a/Process.php +++ b/Process.php @@ -1592,7 +1592,14 @@ function ($m) use (&$env, &$varCache, &$varCount, $uid) { $cmd ); - $cmd = 'cmd /V:ON /E:ON /D /C ('.str_replace("\n", ' ', $cmd).')'; + static $comSpec; + + if (!$comSpec && $comSpec = (new ExecutableFinder())->find('cmd.exe')) { + // Escape according to CommandLineToArgvW rules + $comSpec = '"'.preg_replace('{(\\\\*+)"}', '$1$1\"', $comSpec) .'"'; + } + + $cmd = ($comSpec ?? 'cmd').' /V:ON /E:ON /D /C ('.str_replace("\n", ' ', $cmd).')'; foreach ($this->processPipes->getFiles() as $offset => $filename) { $cmd .= ' '.$offset.'>"'.$filename.'"'; } From 01906871cb9b5e3cf872863b91aba4ec9767daf4 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 6 Nov 2024 10:18:28 +0100 Subject: [PATCH 29/29] [Process] Fix test --- Tests/ExecutableFinderTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ExecutableFinderTest.php b/Tests/ExecutableFinderTest.php index 4aadd9b2..84e5b3c3 100644 --- a/Tests/ExecutableFinderTest.php +++ b/Tests/ExecutableFinderTest.php @@ -123,7 +123,7 @@ public function testFindBatchExecutableOnWindows() $this->markTestSkipped('Can be only tested on windows'); } - $target = tempnam(sys_get_temp_dir(), 'example-windows-executable'); + $target = str_replace('.tmp', '_tmp', tempnam(sys_get_temp_dir(), 'example-windows-executable')); try { touch($target);