mirror of
https://github.com/danog/endtoend-test-psl.git
synced 2024-11-26 12:25:03 +01:00
[Shell] Introduce shell component
This commit is contained in:
parent
c34f75bcb2
commit
81ab0abce6
@ -456,6 +456,9 @@ final class Loader
|
||||
'Psl\Encoding\Base64\decode',
|
||||
'Psl\Encoding\Hex\encode',
|
||||
'Psl\Encoding\Hex\decode',
|
||||
'Psl\Shell\escape_command',
|
||||
'Psl\Shell\escape_argument',
|
||||
'Psl\Shell\execute',
|
||||
];
|
||||
|
||||
public const INTERFACES = [
|
||||
@ -477,6 +480,7 @@ final class Loader
|
||||
'Psl\Type\TypeInterface',
|
||||
'Psl\Type\Exception\ExceptionInterface',
|
||||
'Psl\Regex\Exception\ExceptionInterface',
|
||||
'Psl\Shell\Exception\ExceptionInterface',
|
||||
];
|
||||
|
||||
public const TRAITS = [];
|
||||
@ -525,6 +529,9 @@ final class Loader
|
||||
'Psl\Encoding\Exception\IncorrectPaddingException',
|
||||
'Psl\Encoding\Exception\RangeException',
|
||||
'Psl\Regex\Exception\InvalidPatternException',
|
||||
'Psl\Shell\Exception\FailedExecutionException',
|
||||
'Psl\Shell\Exception\RuntimeException',
|
||||
'Psl\Shell\Exception\PossibleAttackException',
|
||||
];
|
||||
|
||||
private const TYPE_CONSTANTS = 1;
|
||||
|
11
src/Psl/Shell/Exception/ExceptionInterface.php
Normal file
11
src/Psl/Shell/Exception/ExceptionInterface.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Psl\Shell\Exception;
|
||||
|
||||
use Psl\Exception;
|
||||
|
||||
interface ExceptionInterface extends Exception\ExceptionInterface
|
||||
{
|
||||
}
|
50
src/Psl/Shell/Exception/FailedExecutionException.php
Normal file
50
src/Psl/Shell/Exception/FailedExecutionException.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Psl\Shell\Exception;
|
||||
|
||||
use Psl\Str;
|
||||
|
||||
final class FailedExecutionException extends RuntimeException implements ExceptionInterface
|
||||
{
|
||||
private string $command;
|
||||
|
||||
private string $stdoutContent;
|
||||
private string $stderrContent;
|
||||
|
||||
public function __construct(string $command, string $stdout_content, string $stderr_content, int $code)
|
||||
{
|
||||
$message = Str\format('Shell command "%s" returned an exit code of "%d".', $command, $code);
|
||||
|
||||
parent::__construct($message, $code);
|
||||
|
||||
$this->command = $command;
|
||||
$this->stdoutContent = $stdout_content;
|
||||
$this->stderrContent = $stderr_content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-mutation-free
|
||||
*/
|
||||
public function getCommand(): string
|
||||
{
|
||||
return $this->command;
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-mutation-free
|
||||
*/
|
||||
public function getOutput(): string
|
||||
{
|
||||
return $this->stdoutContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-mutation-free
|
||||
*/
|
||||
public function getErrorOutput(): string
|
||||
{
|
||||
return $this->stderrContent;
|
||||
}
|
||||
}
|
9
src/Psl/Shell/Exception/PossibleAttackException.php
Normal file
9
src/Psl/Shell/Exception/PossibleAttackException.php
Normal file
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Psl\Shell\Exception;
|
||||
|
||||
final class PossibleAttackException extends RuntimeException implements ExceptionInterface
|
||||
{
|
||||
}
|
11
src/Psl/Shell/Exception/RuntimeException.php
Normal file
11
src/Psl/Shell/Exception/RuntimeException.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Psl\Shell\Exception;
|
||||
|
||||
use Psl\Exception;
|
||||
|
||||
class RuntimeException extends Exception\RuntimeException implements ExceptionInterface
|
||||
{
|
||||
}
|
62
src/Psl/Shell/escape_argument.php
Normal file
62
src/Psl/Shell/escape_argument.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Psl\Shell;
|
||||
|
||||
use Psl\Regex;
|
||||
use Psl\Str\Byte;
|
||||
|
||||
use const DIRECTORY_SEPARATOR;
|
||||
|
||||
/**
|
||||
* Escape a string to be used as a shell argument.
|
||||
*
|
||||
* @psalm-taint-escape shell
|
||||
*/
|
||||
function escape_argument(string $argument): string
|
||||
{
|
||||
/**
|
||||
* The following code was copied ( with modification ) from the Symfony Process Component (v5.2.3 - 2021-02-22)
|
||||
*
|
||||
* https://github.com/symfony/process/blob/b8d6eff26e48187fed15970799f4b605fa7242e4/Process.php#L1623-L1643
|
||||
*
|
||||
* @license MIT
|
||||
* @see https://github.com/symfony/process/blob/b8d6eff26e48187fed15970799f4b605fa7242e4/LICENSE
|
||||
*
|
||||
* @copyright (c) 2004-2021 Fabien Potencier <fabien@symfony.com>
|
||||
*/
|
||||
if ('' === $argument) {
|
||||
return '""';
|
||||
}
|
||||
|
||||
if ('\\' !== DIRECTORY_SEPARATOR) {
|
||||
$argument = Byte\replace($argument, "'", "'\\''");
|
||||
|
||||
return "'" . $argument . "'";
|
||||
}
|
||||
|
||||
// @codeCoverageIgnoreStart
|
||||
/** @psalm-suppress MissingThrowsDocblock - safe ( $offset is within-of-bounds ) */
|
||||
if (Byte\contains($argument, "\0")) {
|
||||
$argument = Byte\replace($argument, "\0", '?');
|
||||
}
|
||||
|
||||
/** @psalm-suppress MissingThrowsDocblock - safe ( $pattern is valid ) */
|
||||
if (!Regex\matches($argument, '/[\/()%!^"<>&|\s]/')) {
|
||||
return $argument;
|
||||
}
|
||||
|
||||
/** @psalm-suppress MissingThrowsDocblock - safe ( $pattern is valid ) */
|
||||
$argument = Regex\replace($argument, '/(\\\\+)$/', '$1$1');
|
||||
$argument = Byte\replace_every($argument, [
|
||||
'"' => '""',
|
||||
'^' => '"^^"',
|
||||
'%' => '"^%"',
|
||||
'!' => '"^!"',
|
||||
"\n" => '!LF!'
|
||||
]);
|
||||
|
||||
return '"' . $argument . '"';
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
17
src/Psl/Shell/escape_command.php
Normal file
17
src/Psl/Shell/escape_command.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Psl\Shell;
|
||||
|
||||
use function escapeshellcmd;
|
||||
|
||||
/**
|
||||
* Escape shell metacharacters.
|
||||
*
|
||||
* @psalm-taint-escape shell
|
||||
*/
|
||||
function escape_command(string $argument): string
|
||||
{
|
||||
return escapeshellcmd($argument);
|
||||
}
|
89
src/Psl/Shell/execute.php
Normal file
89
src/Psl/Shell/execute.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Psl\Shell;
|
||||
|
||||
use Psl\Dict;
|
||||
use Psl\Env;
|
||||
use Psl\Str;
|
||||
use Psl\Vec;
|
||||
|
||||
use function fclose;
|
||||
use function is_dir;
|
||||
use function is_resource;
|
||||
use function proc_close;
|
||||
use function proc_open;
|
||||
use function stream_get_contents;
|
||||
|
||||
/**
|
||||
* Execute an external program.
|
||||
*
|
||||
* @param string $command The command to execute.
|
||||
* @param list<string> $arguments The command arguments listed as separate entries.
|
||||
* @param string $working_directory The initial working directory for the command.
|
||||
* This must be an absolute directory path, or null
|
||||
* if you want to use the default value (the working dir of the
|
||||
* current directory )
|
||||
* @param array<string, string> $environment An array with the environment variables for the command that
|
||||
* will be run.
|
||||
* @param bool $escape_arguments If set to true ( default ), all $arguments will be escaped using
|
||||
* `Shell\escape_argument`.
|
||||
*
|
||||
* @psalm-taint-sink shell $command
|
||||
*
|
||||
* @throws Exception\FailedExecutionException In case the command resulted in an exit code other than 0.
|
||||
* @throws Exception\PossibleAttackException In case the command being run is suspicious ( e.g: contains NULL byte ).
|
||||
* @throws Exception\RuntimeException In case $working_directory doesn't exist, or unable to create a new
|
||||
* process.
|
||||
*/
|
||||
function execute(
|
||||
string $command,
|
||||
array $arguments = [],
|
||||
?string $working_directory = null,
|
||||
array $environment = [],
|
||||
bool $escape_arguments = true
|
||||
): string {
|
||||
if ($escape_arguments) {
|
||||
$arguments = Vec\map($arguments, static fn(string $argument): string => escape_argument($argument));
|
||||
}
|
||||
|
||||
$commandline = Str\join([$command, ...$arguments], ' ');
|
||||
|
||||
/** @psalm-suppress MissingThrowsDocblock - safe ( $offset is within-of-bounds ) */
|
||||
if (Str\contains($commandline, "\0")) {
|
||||
throw new Exception\PossibleAttackException('NULL byte detected.');
|
||||
}
|
||||
|
||||
$descriptor = [
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$environment = Dict\merge(Env\get_vars(), $environment);
|
||||
$working_directory = $working_directory ?? Env\current_dir();
|
||||
if (!is_dir($working_directory)) {
|
||||
throw new Exception\RuntimeException('$working_directory does not exist.');
|
||||
}
|
||||
|
||||
$process = proc_open($commandline, $descriptor, $pipes, $working_directory, $environment);
|
||||
// @codeCoverageIgnoreStart
|
||||
// not sure how to replicate this, but it can happen \_o.o_/
|
||||
if (!is_resource($process)) {
|
||||
throw new Exception\RuntimeException('Failed to open a new process.');
|
||||
}
|
||||
// @codeCoverageIgnoreEnd
|
||||
|
||||
$stdout_content = stream_get_contents($pipes[1]);
|
||||
$stderr_content = stream_get_contents($pipes[2]);
|
||||
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
|
||||
$code = proc_close($process);
|
||||
if ($code !== 0) {
|
||||
throw new Exception\FailedExecutionException($commandline, $stdout_content, $stderr_content, $code);
|
||||
}
|
||||
|
||||
return $stdout_content;
|
||||
}
|
41
tests/Psl/Shell/EscapeArgumentTest.php
Normal file
41
tests/Psl/Shell/EscapeArgumentTest.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Psl\Tests\Shell;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psl\Shell;
|
||||
|
||||
final class EscapeArgumentTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider provideData
|
||||
*/
|
||||
public function testEscapeArgument(string $argument): void
|
||||
{
|
||||
$output = Shell\execute(PHP_BINARY, ['-r', 'echo $argv[1];', $argument]);
|
||||
|
||||
static::assertSame($argument, $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<array{0: string}>
|
||||
*/
|
||||
public function provideData(): iterable
|
||||
{
|
||||
yield ['a"b%c%'];
|
||||
yield ['a"b^c^'];
|
||||
yield ["a\nb'c"];
|
||||
yield ['a^b c!'];
|
||||
yield ["a!b\tc"];
|
||||
yield ["look up ^"];
|
||||
yield ['a\\\\"\\"'];
|
||||
yield ['éÉèÈàÀöä'];
|
||||
yield ['1'];
|
||||
yield ['1.1'];
|
||||
yield ['1%2'];
|
||||
yield ["Hey there,\nHow are you doing!"];
|
||||
yield [''];
|
||||
}
|
||||
}
|
19
tests/Psl/Shell/EscapeCommandTest.php
Normal file
19
tests/Psl/Shell/EscapeCommandTest.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Psl\Tests\Shell;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psl\Shell;
|
||||
|
||||
final class EscapeCommandTest extends TestCase
|
||||
{
|
||||
public function testEscapeCommand(): void
|
||||
{
|
||||
static::assertSame(
|
||||
"Hello, World!",
|
||||
Shell\execute(Shell\escape_command(PHP_BINARY), ['-r', 'echo "Hello, World!";'])
|
||||
);
|
||||
}
|
||||
}
|
22
tests/Psl/Shell/Exception/FailedExecutionExceptionTest.php
Normal file
22
tests/Psl/Shell/Exception/FailedExecutionExceptionTest.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Psl\Tests\Shell\Exception;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psl\Shell\Exception;
|
||||
|
||||
final class FailedExecutionExceptionTest extends TestCase
|
||||
{
|
||||
public function testMethods(): void
|
||||
{
|
||||
$exception = new Exception\FailedExecutionException('foo', 'bar', 'baz', 4);
|
||||
|
||||
static::assertSame('Shell command "foo" returned an exit code of "4".', $exception->getMessage());
|
||||
static::assertSame('foo', $exception->getCommand());
|
||||
static::assertSame('bar', $exception->getOutput());
|
||||
static::assertSame('baz', $exception->getErrorOutput());
|
||||
static::assertSame(4, $exception->getCode());
|
||||
}
|
||||
}
|
93
tests/Psl/Shell/ExecuteTest.php
Normal file
93
tests/Psl/Shell/ExecuteTest.php
Normal file
@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Psl\Tests\Shell;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psl\Env;
|
||||
use Psl\SecureRandom;
|
||||
use Psl\Shell;
|
||||
|
||||
use const PHP_OS_FAMILY;
|
||||
|
||||
final class ExecuteTest extends TestCase
|
||||
{
|
||||
public function testExecute(): void
|
||||
{
|
||||
static::assertSame(
|
||||
"Hello, World!",
|
||||
Shell\execute(PHP_BINARY, ['-r', 'echo "Hello, World!";'])
|
||||
);
|
||||
}
|
||||
|
||||
public function testFailedExecution(): void
|
||||
{
|
||||
try {
|
||||
Shell\execute('php', ['-r', 'write("Hello, World!");']);
|
||||
} catch (Shell\Exception\FailedExecutionException $exception) {
|
||||
static::assertSame(255, $exception->getCode());
|
||||
static::assertStringContainsString('Call to undefined function write()', $exception->getErrorOutput());
|
||||
static::assertStringContainsString('php', $exception->getCommand());
|
||||
}
|
||||
}
|
||||
|
||||
public function testItThrowsForNULLByte(): void
|
||||
{
|
||||
$this->expectException(Shell\Exception\PossibleAttackException::class);
|
||||
|
||||
Shell\execute('php', ["\0"]);
|
||||
}
|
||||
|
||||
public function testEnvironmentIsPassedDownToTheProcess(): void
|
||||
{
|
||||
static::assertSame(
|
||||
'BAR',
|
||||
Shell\execute(PHP_BINARY, ['-r', 'echo getenv("FOO");'], null, ['FOO' => 'BAR'])
|
||||
);
|
||||
}
|
||||
|
||||
public function testCurrentEnvironmentVariablesArePassedDownToTheProcess(): void
|
||||
{
|
||||
Env\set_var('FOO', 'BAR');
|
||||
|
||||
static::assertSame(
|
||||
'BAR',
|
||||
Shell\execute(PHP_BINARY, ['-r', 'echo getenv("FOO");'])
|
||||
);
|
||||
}
|
||||
|
||||
public function testWorkingDirectoryIsUsed(): void
|
||||
{
|
||||
if ('Darwin' === PHP_OS_FAMILY) {
|
||||
static::markTestSkipped();
|
||||
}
|
||||
|
||||
$temp = Env\temp_dir();
|
||||
|
||||
static::assertSame(
|
||||
$temp,
|
||||
Shell\execute(PHP_BINARY, ['-r', 'echo getcwd();'], $temp)
|
||||
);
|
||||
}
|
||||
|
||||
public function testCurrentDirectoryIsUsedByDefault(): void
|
||||
{
|
||||
$dir = Env\current_dir();
|
||||
|
||||
static::assertSame(
|
||||
$dir,
|
||||
Shell\execute(PHP_BINARY, ['-r', 'echo getcwd();'])
|
||||
);
|
||||
}
|
||||
|
||||
public function testItThrowsWhenWorkingDirectoryDoesntExist(): void
|
||||
{
|
||||
$dir = Env\current_dir() . DIRECTORY_SEPARATOR . SecureRandom\string(6);
|
||||
|
||||
$this->expectException(Shell\Exception\RuntimeException::class);
|
||||
$this->expectExceptionMessage('$working_directory does not exist.');
|
||||
|
||||
Shell\execute(PHP_BINARY, ['-r', 'echo getcwd();'], $dir);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user