[Shell] Introduce shell component

This commit is contained in:
azjezz 2021-02-22 02:33:21 +01:00 committed by Saif Eddin Gmati
parent c34f75bcb2
commit 81ab0abce6
12 changed files with 431 additions and 0 deletions

View File

@ -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;

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Psl\Shell\Exception;
use Psl\Exception;
interface ExceptionInterface extends Exception\ExceptionInterface
{
}

View 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;
}
}

View File

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Psl\Shell\Exception;
final class PossibleAttackException extends RuntimeException implements ExceptionInterface
{
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Psl\Shell\Exception;
use Psl\Exception;
class RuntimeException extends Exception\RuntimeException implements ExceptionInterface
{
}

View 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
}

View 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
View 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;
}

View 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 [''];
}
}

View 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!";'])
);
}
}

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

View 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);
}
}