diff --git a/src/Psl/Internal/Loader.php b/src/Psl/Internal/Loader.php index fb4e173..5ca5ac5 100644 --- a/src/Psl/Internal/Loader.php +++ b/src/Psl/Internal/Loader.php @@ -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; diff --git a/src/Psl/Shell/Exception/ExceptionInterface.php b/src/Psl/Shell/Exception/ExceptionInterface.php new file mode 100644 index 0000000..cc1b09b --- /dev/null +++ b/src/Psl/Shell/Exception/ExceptionInterface.php @@ -0,0 +1,11 @@ +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; + } +} diff --git a/src/Psl/Shell/Exception/PossibleAttackException.php b/src/Psl/Shell/Exception/PossibleAttackException.php new file mode 100644 index 0000000..2e4ecec --- /dev/null +++ b/src/Psl/Shell/Exception/PossibleAttackException.php @@ -0,0 +1,9 @@ + + */ + 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 +} diff --git a/src/Psl/Shell/escape_command.php b/src/Psl/Shell/escape_command.php new file mode 100644 index 0000000..6c26ce6 --- /dev/null +++ b/src/Psl/Shell/escape_command.php @@ -0,0 +1,17 @@ + $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 $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; +} diff --git a/tests/Psl/Shell/EscapeArgumentTest.php b/tests/Psl/Shell/EscapeArgumentTest.php new file mode 100644 index 0000000..4bb9f6d --- /dev/null +++ b/tests/Psl/Shell/EscapeArgumentTest.php @@ -0,0 +1,41 @@ + + */ + 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 ['']; + } +} diff --git a/tests/Psl/Shell/EscapeCommandTest.php b/tests/Psl/Shell/EscapeCommandTest.php new file mode 100644 index 0000000..ec8d602 --- /dev/null +++ b/tests/Psl/Shell/EscapeCommandTest.php @@ -0,0 +1,19 @@ +getMessage()); + static::assertSame('foo', $exception->getCommand()); + static::assertSame('bar', $exception->getOutput()); + static::assertSame('baz', $exception->getErrorOutput()); + static::assertSame(4, $exception->getCode()); + } +} diff --git a/tests/Psl/Shell/ExecuteTest.php b/tests/Psl/Shell/ExecuteTest.php new file mode 100644 index 0000000..228cc39 --- /dev/null +++ b/tests/Psl/Shell/ExecuteTest.php @@ -0,0 +1,93 @@ +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); + } +}