diff --git a/src/ShipItShellCommand.php b/src/ShipItShellCommand.php new file mode 100644 index 00000000..229e6b03 --- /dev/null +++ b/src/ShipItShellCommand.php @@ -0,0 +1,146 @@ + $command; + + private Map $environmentVariables = Map {}; + private bool $throwForNonZeroExit = true; + private ?string $stdin = null; + private bool $outputToScreen = false; + + public function __construct( + private string $path, + ...$command + ) { + $this->command = new ImmVector($command); + } + + public function setStdIn(string $input): this { + $this->stdin = $input; + return $this; + } + + public function setOutputToScreen(): this { + $this->outputToScreen = true; + return $this; + } + + public function setEnvironmentVariables( + ImmMap $vars, + ): this { + $this->environmentVariables->setAll($vars); + return $this; + } + + public function setNoExceptions(): this { + $this->throwForNonZeroExit = false; + return $this; + } + + public function runSynchronously(): ShipItShellCommandResult { + $command = implode(' ', $this->command->map($str ==> escapeshellarg($str))); + + $fds = array( + 0 => array('pipe', 'r'), + 1 => array('pipe', 'w'), + 2 => array('pipe', 'w'), + ); + $stdin = $this->stdin; + if ($stdin === null) { + unset($fds[0]); + } + + $pipes = null; + $fp = proc_open( + $command, + $fds, + $pipes, + $this->path, + $this->environmentVariables->toArray(), + ); + if (!$fp || !is_array($pipes)) { + throw new \Exception("Failed executing $command"); + } + if ($stdin !== null) { + while (strlen($stdin)) { + $written = fwrite($pipes[0], $stdin); + $stdin = substr($stdin, $written); + } + fclose($pipes[0]); + } + + $stdout_stream = $pipes[1]; + $stderr_stream = $pipes[2]; + stream_set_blocking($stdout_stream, false); + stream_set_blocking($stderr_stream, false); + $stdout = ''; + $stderr = ''; + while (true) { + $ready_streams = [$stdout_stream, $stderr_stream]; + $null_byref = null; + $result = stream_select( + $ready_streams, + /* write streams = */ $null_byref, + /* exception streams = */ $null_byref, + /* timeout = */ null, + ); + if ($result === false) { + break; + } + $all_empty = true; + foreach ($ready_streams as $stream) { + $out = fread($stream, 1024); + if (strlen($out) === 0) { + continue; + } + $all_empty = false; + + if ($stream === $stdout_stream) { + $stdout .= $out; + $this->maybeFwrite(STDOUT, $out); + continue; + } + if ($stream === $stderr_stream) { + $stderr .= $out; + $this->maybeFwrite(STDERR, $out); + continue; + } + + invariant_violation('Unhandled stream!'); + } + + if ($all_empty) { + break; + } + } + $exitcode = proc_close($fp); + + $result = new ShipItShellCommandResult( + $exitcode, + $stdout, + $stderr, + ); + + if ($exitcode !== 0 && $this->throwForNonZeroExit) { + throw new ShipItShellCommandException($command, $result); + } + + return $result; + } + + private function maybeFwrite(resource $stream, string $out): void { + if (!$this->outputToScreen) { + return; + } + fwrite($stream, $out); + } +} diff --git a/src/ShipItShellCommandException.php b/src/ShipItShellCommandException.php new file mode 100644 index 00000000..a745f344 --- /dev/null +++ b/src/ShipItShellCommandException.php @@ -0,0 +1,37 @@ +getExitCode(); + $error = $result->getStdErr(); + parent::__construct("$command returned exit code $exitCode: $error"); + } + + public function getError(): string { + return $this->result->getStdErr(); + } + + public function getExitCode(): int { + return $this->result->getExitCode(); + } + + public function getOutput(): string { + return $this->result->getStdOut(); + } + + public function getResult(): ShipItShellCommandResult { + return $this->result; + } +} diff --git a/src/ShipItShellCommandResult.php b/src/ShipItShellCommandResult.php new file mode 100644 index 00000000..ca290ae6 --- /dev/null +++ b/src/ShipItShellCommandResult.php @@ -0,0 +1,31 @@ +exitCode; + } + + public function getStdOut(): string { + return $this->stdout; + } + + public function getStdErr(): string { + return $this->stderr; + } +} diff --git a/src/ShipItUtil.php b/src/ShipItUtil.php index a88c3819..0f63888a 100644 --- a/src/ShipItUtil.php +++ b/src/ShipItUtil.php @@ -12,29 +12,6 @@ type ShipItAffectedFile = string; type ShipItDiffAsString = string; -class ShipItShellCommandException extends \Exception { - public function __construct( - private string $command, - private int $exitCode, - private string $output, - private string $error, - ) { - parent::__construct("$command returned exit code $exitCode: $error"); - } - - public function getError(): string { - return $this->error; - } - - public function getExitCode(): int { - return $this->exitCode; - } - - public function getOutput(): string { - return $this->output; - } -} - abstract class ShipItUtil { const SHORT_REV_LENGTH = 7; // flags for shellExec, no flag equal to 1 @@ -164,98 +141,40 @@ public static function isFileRemoval(string $body): bool { return (bool) preg_match('@^deleted file@m', $body); } - // readStreams reads from multiple streams in "parallel" - // (by using stream_select) which ensures that - // reading process won't block waiting for data in one stream when it - // appears in other - private static function readStreams( - array $streams, - ): array { - $outs = array_fill(0, count($streams), ''); - $reverse_map = array(); - - for ($i = 0; $i < count($streams); ++$i) { - stream_set_blocking($streams[$i], 0); - $reverse_map[$streams[$i]] = $i; - } - $stop = false; - while (!$stop) { - $null = null; - $ready_streams = $streams; - if (!stream_select($ready_streams, $null, $null, null)) { - $stop = true; - } else { - $all_empty = true; - foreach ($ready_streams as $stream) { - $out = fread($stream, 1024); - if ($out !== false && strlen($out) !== 0) { - $all_empty = false; - $outs[$reverse_map[$stream]] .= $out; - } - } - $stop = $all_empty; - } - } - return $outs; - } - public static function shellExec( string $path, ?string $stdin, int $flags, ...$args ): string { - $fds = array( - 0 => array('pipe', 'r'), - 1 => array('pipe', 'w'), - 2 => array('pipe', 'w'), - ); - if ($stdin === null) { - unset($fds[0]); - } + $command = new ShipItShellCommand($path, ...$args); - $argn = null; - foreach ($args as &$argn) { - $argn = escapeshellarg($argn); - } - unset($argn); - - $cmd = implode(' ', $args); if ($flags & self::VERBOSE_SHELL) { + $cmd = implode(' ', $args); fwrite(STDERR, "\$ $cmd\n"); } - $pipes = null; - $fp = proc_open($cmd, $fds, $pipes, $path); - if (!$fp || !is_array($pipes)) { - throw new \Exception("Failed executing $cmd"); - } + + if ($stdin !== null) { if ($flags & self::VERBOSE_SHELL_INPUT) { fwrite(STDERR, "--STDIN--\n$stdin\n"); } - while (strlen($stdin)) { - $written = fwrite($pipes[0], $stdin); - $stdin = substr($stdin, $written); - } - fclose($pipes[0]); + $command->setStdIn($stdin); } - list($output, $error) = self::readStreams(array($pipes[1], $pipes[2])); + if ($flags & self::VERBOSE_SHELL_OUTPUT) { - if ($error) { - fwrite(STDERR, "--STDERR--\n$error\n"); - } - if ($output) { - fwrite(STDERR, "--STDOUT--\n$output\n"); - } + $command->setOutputToScreen(); } - $exitcode = proc_close($fp); - if ($exitcode && !($flags & self::NO_THROW)) { - throw new ShipItShellCommandException($cmd, $exitcode, $output, $error); + if ($flags && self::NO_THROW) { + $command->setNoExceptions(); } + $result = $command->runSynchronously(); + + $output = $result->getStdOut(); if ($flags & self::RETURN_STDERR) { - return $output."\n".$error; + $output .= "\n".$result->getStdErr(); } return $output; } diff --git a/tests/ShipItShellCommandTest.php b/tests/ShipItShellCommandTest.php new file mode 100644 index 00000000..a7fdfe03 --- /dev/null +++ b/tests/ShipItShellCommandTest.php @@ -0,0 +1,86 @@ +runSynchronously(); + $this->assertSame(0, $result->getExitCode()); + } + + public function testExitOneException(): void { + try { + (new ShipItShellCommand('/', 'false'))->runSynchronously(); + $this->markTestFailed('Expected exception'); + } catch (ShipItShellCommandException $e) { + $this->assertSame(1, $e->getExitCode()); + } + } + + public function testExitOneWithoutException(): void { + $result = (new ShipItShellCommand('/', 'false')) + ->setNoExceptions() + ->runSynchronously(); + $this->assertSame(1, $result->getExitCode()); + } + + public function testStdIn(): void { + $result = (new ShipItShellCommand('/', 'cat')) + ->setStdIn('Hello, world.') + ->runSynchronously(); + $this->assertSame('Hello, world.', $result->getStdOut()); + $this->assertSame('', $result->getStdErr()); + } + + public function testEnvironmentVariables(): void { + $herp = bin2hex(random_bytes(16)); + $result = (new ShipItShellCommand('/', 'env')) + ->setEnvironmentVariables(ImmMap { 'HERP' => $herp }) + ->runSynchronously(); + $this->assertContains( + 'HERP='.$herp, + $result->getStdOut(), + ); + } + + public function testWorkingDirectory(): void { + $this->assertSame( + '/', + (new ShipItShellCommand('/', 'pwd')) + ->runSynchronously() + ->getStdOut() + |> trim($$), + ); + + $tmp = sys_get_temp_dir(); + $this->assertSame( + $tmp, + (new ShipItShellCommand($tmp, 'pwd')) + ->runSynchronously() + ->getStdOut() + |> trim($$), + ); + } + + public function testMultipleArguments(): void { + $output = (new ShipItShellCommand('/', 'echo', '-n', 'foo', 'bar')) + ->runSynchronously() + ->getStdOut(); + $this->assertSame('foo bar', $output); + } + + public function testEscaping(): void { + $output = (new ShipItShellCommand('/', 'echo', 'foo', '$FOO')) + ->setEnvironmentVariables(ImmMap { 'FOO' => 'variable value' }) + ->runSynchronously() + ->getStdOut(); + $this->assertSame("foo \$FOO\n", $output); + } +}