Skip to content

Commit

Permalink
[Shell] Introduce shell component
Browse files Browse the repository at this point in the history
  • Loading branch information
azjezz committed Feb 22, 2021
1 parent c34f75b commit 0c77293
Show file tree
Hide file tree
Showing 12 changed files with 431 additions and 0 deletions.
7 changes: 7 additions & 0 deletions src/Psl/Internal/Loader.php
Expand Up @@ -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 = [
Expand All @@ -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 = [];
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/Psl/Shell/Exception/ExceptionInterface.php
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Psl\Shell\Exception;

use Psl\Exception;

interface ExceptionInterface extends Exception\ExceptionInterface
{
}
50 changes: 50 additions & 0 deletions src/Psl/Shell/Exception/FailedExecutionException.php
@@ -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 changes: 9 additions & 0 deletions src/Psl/Shell/Exception/PossibleAttackException.php
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Psl\Shell\Exception;

final class PossibleAttackException extends RuntimeException implements ExceptionInterface
{
}
11 changes: 11 additions & 0 deletions src/Psl/Shell/Exception/RuntimeException.php
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Psl\Shell\Exception;

use Psl\Exception;

class RuntimeException extends Exception\RuntimeException implements ExceptionInterface
{
}
62 changes: 62 additions & 0 deletions src/Psl/Shell/escape_argument.php
@@ -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 changes: 17 additions & 0 deletions src/Psl/Shell/escape_command.php
@@ -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 changes: 89 additions & 0 deletions src/Psl/Shell/execute.php
@@ -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 changes: 41 additions & 0 deletions tests/Psl/Shell/EscapeArgumentTest.php
@@ -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 changes: 19 additions & 0 deletions tests/Psl/Shell/EscapeCommandTest.php
@@ -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 changes: 22 additions & 0 deletions tests/Psl/Shell/Exception/FailedExecutionExceptionTest.php
@@ -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());
}
}

0 comments on commit 0c77293

Please sign in to comment.