From ec651dc5137a295264a020e2a40831e5842ebc3c Mon Sep 17 00:00:00 2001 From: Saif Eddin Gmati <29315886+azjezz@users.noreply.github.com> Date: Wed, 10 Nov 2021 20:38:42 +0100 Subject: [PATCH] fix: fix windows support (#268) Signed-off-by: azjezz --- .github/workflows/unit-tests.yml | 1 + docs/component/io.md | 4 +- docs/component/shell.md | 2 +- docs/component/unix.md | 2 +- src/Psl/Filesystem/create_temporary_file.php | 6 +- src/Psl/IO/Reader.php | 4 +- src/Psl/IO/pipe.php | 5 +- src/Psl/Shell/execute.php | 88 ++++++++++++++++++-- src/Psl/Unix/Server.php | 11 ++- src/Psl/Unix/connect.php | 6 ++ tests/unit/Async/AwaitReadableTest.php | 9 +- tests/unit/Env/CurrentExecTest.php | 8 +- tests/unit/Filesystem/CopyTest.php | 6 ++ tests/unit/Filesystem/FileTest.php | 11 ++- tests/unit/Filesystem/PermissionsTest.php | 7 ++ tests/unit/Filesystem/ReadDirectoryTest.php | 7 ++ tests/unit/Shell/EscapeCommandTest.php | 1 - tests/unit/Shell/ExecuteTest.php | 22 +++-- tests/unit/Unix/ConnectTest.php | 8 +- tests/unit/Unix/ServerTest.php | 14 ++++ 20 files changed, 190 insertions(+), 32 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 78ca40af..caf89e6a 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -17,6 +17,7 @@ jobs: operating-system: - "macos-latest" - "ubuntu-latest" + - "windows-latest" steps: - name: "checkout" diff --git a/docs/component/io.md b/docs/component/io.md index da92b6ca..a1728813 100644 --- a/docs/component/io.md +++ b/docs/component/io.md @@ -15,7 +15,7 @@ - [error_handle](./../../src/Psl/IO/error_handle.php#L17) - [input_handle](./../../src/Psl/IO/input_handle.php#L17) - [output_handle](./../../src/Psl/IO/output_handle.php#L17) -- [pipe](./../../src/Psl/IO/pipe.php#L22) +- [pipe](./../../src/Psl/IO/pipe.php#L24) #### `Interfaces` @@ -39,7 +39,7 @@ #### `Classes` - [MemoryHandle](./../../src/Psl/IO/MemoryHandle.php#L15) -- [Reader](./../../src/Psl/IO/Reader.php#L15) +- [Reader](./../../src/Psl/IO/Reader.php#L17) #### `Traits` diff --git a/docs/component/shell.md b/docs/component/shell.md index c523daa4..18d562cf 100644 --- a/docs/component/shell.md +++ b/docs/component/shell.md @@ -14,6 +14,6 @@ - [escape_argument](./../../src/Psl/Shell/escape_argument.php#L17) - [escape_command](./../../src/Psl/Shell/escape_command.php#L14) -- [execute](./../../src/Psl/Shell/execute.php#L39) +- [execute](./../../src/Psl/Shell/execute.php#L44) diff --git a/docs/component/unix.md b/docs/component/unix.md index d300e6a0..36a68d69 100644 --- a/docs/component/unix.md +++ b/docs/component/unix.md @@ -20,6 +20,6 @@ #### `Classes` -- [Server](./../../src/Psl/Unix/Server.php#L15) +- [Server](./../../src/Psl/Unix/Server.php#L16) diff --git a/src/Psl/Filesystem/create_temporary_file.php b/src/Psl/Filesystem/create_temporary_file.php index 62e81dda..518e6377 100644 --- a/src/Psl/Filesystem/create_temporary_file.php +++ b/src/Psl/Filesystem/create_temporary_file.php @@ -34,16 +34,16 @@ function create_temporary_file(?string $directory = null, ?string $prefix = null if (null !== $prefix) { Psl\invariant( - !Str\contains($prefix, SEPARATOR), + !Str\contains($prefix, ((string)SEPARATOR)), '$prefix should not contain a directory separator ( "%s" ).', - SEPARATOR + ((string)SEPARATOR) ); } else { $prefix = ''; } try { - $filename = $directory . '/' . $prefix . SecureRandom\string(8); + $filename = $directory . ((string)SEPARATOR) . $prefix . SecureRandom\string(8); // @codeCoverageIgnoreStart } catch (SecureRandom\Exception\InsufficientEntropyException $e) { throw new Exception\RuntimeException('Unable to gather enough entropy to generate filename.', 0, $e); diff --git a/src/Psl/IO/Reader.php b/src/Psl/IO/Reader.php index b07e39de..32088078 100644 --- a/src/Psl/IO/Reader.php +++ b/src/Psl/IO/Reader.php @@ -12,6 +12,8 @@ use function strpos; use function substr; +use const PHP_EOL; + final class Reader implements ReadHandleInterface { use ReadHandleConvenienceMethodsTrait; @@ -139,7 +141,7 @@ public function readByte(?float $timeout = null): string */ public function readLine(): ?string { - $line = $this->readUntil("\n"); + $line = $this->readUntil(PHP_EOL); if (null !== $line) { return $line; } diff --git a/src/Psl/IO/pipe.php b/src/Psl/IO/pipe.php index 1584ebc9..4bf8fd96 100644 --- a/src/Psl/IO/pipe.php +++ b/src/Psl/IO/pipe.php @@ -10,7 +10,9 @@ use function error_get_last; use function stream_socket_pair; +use const PHP_OS_FAMILY; use const STREAM_IPPROTO_IP; +use const STREAM_PF_INET; use const STREAM_PF_UNIX; use const STREAM_SOCK_STREAM; @@ -26,7 +28,8 @@ function pipe(): array * @return array{0: resource, 1: resource} */ static function (): array { - $sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + $domain = PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX; + $sockets = stream_socket_pair($domain, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); // @codeCoverageIgnoreStart if ($sockets === false) { $error = error_get_last(); diff --git a/src/Psl/Shell/execute.php b/src/Psl/Shell/execute.php index a5e6693e..a2fc3224 100644 --- a/src/Psl/Shell/execute.php +++ b/src/Psl/Shell/execute.php @@ -9,6 +9,8 @@ use Psl\Env; use Psl\IO; use Psl\IO\Stream; +use Psl\Regex; +use Psl\SecureRandom; use Psl\Str; use Psl\Vec; @@ -16,6 +18,9 @@ use function is_resource; use function proc_close; use function proc_open; +use function strpbrk; + +use const PHP_OS_FAMILY; /** * Execute an external program. @@ -65,18 +70,87 @@ function execute( 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(); + $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); + $options = []; + // @codeCoverageIgnoreStart + if (PHP_OS_FAMILY === 'Windows') { + $variable_cache = []; + $variable_count = 0; + /** @psalm-suppress MissingThrowsDocblock */ + $identifier = 'PHP_STANDARD_LIBRARY_TMP_ENV_' . SecureRandom\string(6); + /** @psalm-suppress MissingThrowsDocblock */ + $commandline = Regex\replace_with( + $commandline, + '/"(?:([^"%!^]*+(?:(?:!LF!|"(?:\^[%!^])?+")[^"%!^]*+)++)|[^"]*+ )"/x', + /** + * @param array $m + * + * @return string + */ + static function (array $m) use ( + &$environment, + &$variable_cache, + &$variable_count, + $identifier + ): string { + if (!isset($m[1])) { + return $m[0]; + } + + /** @var array $variable_cache */ + if (isset($variable_cache[$m[0]])) { + /** @var string */ + return $variable_cache[$m[0]]; + } + + $value = $m[1]; + if (Str\Byte\contains($value, "\0")) { + $value = Str\Byte\replace($value, "\0", '?'); + } + + if (false === strpbrk($value, "\"%!\n")) { + return '"' . $value . '"'; + } + + $value = Str\Byte\replace_every($value, ['!LF!' => "\n", '"^!"' => '!', '"^%"' => '%', '"^^"' => '^', '""' => '"']); + $value = '"' . Regex\replace($value, '/(\\\\*)"/', '$1$1\\"') . '"'; + /** + * @psalm-suppress MixedAssignment + * @psalm-suppress MixedOperand + */ + $var = $identifier . ++$variable_count; + + /** + * @psalm-suppress MixedArrayAssignment + */ + $environment[$var] = $value; + + /** + * @psalm-suppress MixedArrayOffset + * @psalm-suppress MixedArrayAssignment + */ + return $variable_cache[$m[0]] = '!' . $var . '!'; + }, + ); + + $commandline = 'cmd /V:ON /E:ON /D /C (' . Str\Byte\replace($commandline, "\n", ' ') . ')'; + $options = [ + 'bypass_shell' => true, + 'blocking_pipes' => false, + ]; + } + // @codeCoverageIgnoreEnd + $descriptor = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + /** @var array $environment */ + $process = proc_open($commandline, $descriptor, $pipes, $working_directory, $environment, $options); // @codeCoverageIgnoreStart // not sure how to replicate this, but it can happen \_o.o_/ if (!is_resource($process)) { diff --git a/src/Psl/Unix/Server.php b/src/Psl/Unix/Server.php index a5daff38..7d4de06c 100644 --- a/src/Psl/Unix/Server.php +++ b/src/Psl/Unix/Server.php @@ -4,7 +4,6 @@ namespace Psl\Unix; -use Psl; use Psl\Network; use Revolt\EventLoop; @@ -12,6 +11,8 @@ use function fclose; use function stream_socket_accept; +use const PHP_OS_FAMILY; + final class Server implements Network\ServerInterface { /** @@ -62,10 +63,16 @@ static function (string $_watcher, mixed $resource) use (&$suspension): void { * * @param non-empty-string $file * - * @throws Psl\Network\Exception\RuntimeException In case failed to listen to on given address. + * @throws Network\Exception\RuntimeException In case failed to listen to on given address. */ public static function create(string $file): self { + // @codeCoverageIgnoreStart + if (PHP_OS_FAMILY === 'Windows') { + throw new Network\Exception\RuntimeException('Unix server is not supported on Windows platform.'); + } + // @codeCoverageIgnoreEnd + $socket = Network\Internal\server_listen("unix://{$file}"); return new self($socket); diff --git a/src/Psl/Unix/connect.php b/src/Psl/Unix/connect.php index e756fa4b..ae423c49 100644 --- a/src/Psl/Unix/connect.php +++ b/src/Psl/Unix/connect.php @@ -16,6 +16,12 @@ */ function connect(string $path, ?float $timeout = null): SocketInterface { + // @codeCoverageIgnoreStart + if (PHP_OS_FAMILY === 'Windows') { + throw new Network\Exception\RuntimeException('Unix socket is not supported on Windows platform.'); + } + // @codeCoverageIgnoreEnd + $socket = Network\Internal\socket_connect("unix://{$path}", timeout: $timeout); /** @psalm-suppress MissingThrowsDocblock */ diff --git a/tests/unit/Async/AwaitReadableTest.php b/tests/unit/Async/AwaitReadableTest.php index c084a984..7d53088f 100644 --- a/tests/unit/Async/AwaitReadableTest.php +++ b/tests/unit/Async/AwaitReadableTest.php @@ -11,11 +11,18 @@ use function fwrite; use function stream_socket_pair; +use const PHP_OS_FAMILY; +use const STREAM_IPPROTO_IP; +use const STREAM_PF_INET; +use const STREAM_PF_UNIX; +use const STREAM_SOCK_STREAM; + final class AwaitReadableTest extends TestCase { public function testAwaitReadable(): void { - $sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + $domain = PHP_OS_FAMILY === 'Windows' ? STREAM_PF_INET : STREAM_PF_UNIX; + $sockets = stream_socket_pair($domain, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); $write_socket = $sockets[0]; $read_socket = $sockets[1]; diff --git a/tests/unit/Env/CurrentExecTest.php b/tests/unit/Env/CurrentExecTest.php index 4ed9ebec..8b509ea8 100644 --- a/tests/unit/Env/CurrentExecTest.php +++ b/tests/unit/Env/CurrentExecTest.php @@ -8,13 +8,19 @@ use Psl\Env; use Psl\Filesystem; +use const PHP_OS_FAMILY; + final class CurrentExecTest extends TestCase { public function testCurrentExe(): void { + if ('Windows' === PHP_OS_FAMILY) { + static::markTestSkipped('I do not want to bother :)'); + } + $phpunit = __DIR__ . '/../../../vendor/bin/phpunit'; $phpunit = Filesystem\canonicalize($phpunit); - if (PHP_OS_FAMILY !== 'Windows' && Filesystem\is_symbolic_link($phpunit)) { + if (Filesystem\is_symbolic_link($phpunit)) { $phpunit = Filesystem\read_symbolic_link($phpunit); } diff --git a/tests/unit/Filesystem/CopyTest.php b/tests/unit/Filesystem/CopyTest.php index f7810cb0..0adf299d 100644 --- a/tests/unit/Filesystem/CopyTest.php +++ b/tests/unit/Filesystem/CopyTest.php @@ -7,6 +7,8 @@ use Psl\Filesystem; use Psl\Str; +use const PHP_OS_FAMILY; + final class CopyTest extends AbstractFilesystemTest { protected string $function = 'copy'; @@ -42,6 +44,10 @@ public function testCopyOverwrite(): void public function testCopyExecutableBits(): void { + if (PHP_OS_FAMILY === 'Windows') { + static::markTestSkipped('Test can only be executed under *nix OS.'); + } + $shell_file = Str\join([$this->directory, 'hello.sh'], Filesystem\SEPARATOR); Filesystem\create_file($shell_file); diff --git a/tests/unit/Filesystem/FileTest.php b/tests/unit/Filesystem/FileTest.php index c16cea9b..f8e53ed3 100644 --- a/tests/unit/Filesystem/FileTest.php +++ b/tests/unit/Filesystem/FileTest.php @@ -138,12 +138,17 @@ public function testWriteFileThrowsForNonWritableFiles(): void { $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR); Filesystem\create_file($file); + $permissions = Filesystem\get_permissions($file) & 0777; Filesystem\change_permissions($file, 0111); - $this->expectException(InvariantViolationException::class); - $this->expectExceptionMessage('File "' . $file . '" is not writable.'); + try { + $this->expectException(InvariantViolationException::class); + $this->expectExceptionMessage('File "' . $file . '" is not writable.'); - Filesystem\write_file($file, 'hello'); + Filesystem\write_file($file, 'hello'); + } finally { + Filesystem\change_permissions($file, $permissions); + } } public function testReadFile(): void diff --git a/tests/unit/Filesystem/PermissionsTest.php b/tests/unit/Filesystem/PermissionsTest.php index 45c72da7..64c834a3 100644 --- a/tests/unit/Filesystem/PermissionsTest.php +++ b/tests/unit/Filesystem/PermissionsTest.php @@ -7,12 +7,19 @@ use Psl\Filesystem; use Psl\Str; +use const PHP_OS_FAMILY; + final class PermissionsTest extends AbstractFilesystemTest { protected string $function = 'permissions'; public function testChangePermissions(): void { + if (PHP_OS_FAMILY === 'Windows') { + // executable bit on windows. + static::markTestSkipped('Test can only be executed under *nix OS.'); + } + $filename = Str\join([$this->directory, 'foo.txt'], Filesystem\SEPARATOR); Filesystem\create_file($filename); diff --git a/tests/unit/Filesystem/ReadDirectoryTest.php b/tests/unit/Filesystem/ReadDirectoryTest.php index 9a7c97ae..00c4aa81 100644 --- a/tests/unit/Filesystem/ReadDirectoryTest.php +++ b/tests/unit/Filesystem/ReadDirectoryTest.php @@ -10,6 +10,8 @@ use Psl\Str; use Psl\Vec; +use const PHP_OS_FAMILY; + final class ReadDirectoryTest extends AbstractFilesystemTest { protected string $function = 'read_directory'; @@ -57,6 +59,11 @@ public function testReadDirectoryThrowsIfNotDirectory(): void public function testReadDirectoryThrowsIfNotReadable(): void { + if (PHP_OS_FAMILY === 'Windows') { + // executable bit on windows. + static::markTestSkipped('Test can only be executed under *nix OS.'); + } + Filesystem\change_permissions($this->directory, 0077); $this->expectException(InvariantViolationException::class); diff --git a/tests/unit/Shell/EscapeCommandTest.php b/tests/unit/Shell/EscapeCommandTest.php index ce1ec35e..09e4b714 100644 --- a/tests/unit/Shell/EscapeCommandTest.php +++ b/tests/unit/Shell/EscapeCommandTest.php @@ -11,7 +11,6 @@ final class EscapeCommandTest extends TestCase { public function testEscapeCommand(): void { - static::assertSame( "Hello, World!", Shell\execute(Shell\escape_command(PHP_BINARY), ['-r', 'echo "Hello, World!";']) diff --git a/tests/unit/Shell/ExecuteTest.php b/tests/unit/Shell/ExecuteTest.php index c4c099f6..380cd822 100644 --- a/tests/unit/Shell/ExecuteTest.php +++ b/tests/unit/Shell/ExecuteTest.php @@ -34,6 +34,10 @@ public function testFailedExecution(): void public function testItThrowsForNULLByte(): void { + if (PHP_OS_FAMILY === 'Windows') { + static::markTestSkipped('Test can only be executed under *nix OS.'); + } + $this->expectException(Shell\Exception\PossibleAttackException::class); Shell\execute('php', ["\0"]); @@ -49,17 +53,21 @@ public function testEnvironmentIsPassedDownToTheProcess(): void public function testCurrentEnvironmentVariablesArePassedDownToTheProcess(): void { - Env\set_var('FOO', 'BAR'); - - static::assertSame( - 'BAR', - Shell\execute(PHP_BINARY, ['-r', 'echo getenv("FOO");']) - ); + try { + Env\set_var('FOO', 'BAR'); + + static::assertSame( + 'BAR', + Shell\execute(PHP_BINARY, ['-r', 'echo getenv("FOO");']) + ); + } finally { + Env\remove_var('FOO'); + } } public function testWorkingDirectoryIsUsed(): void { - if ('Darwin' === PHP_OS_FAMILY) { + if ('Darwin' === PHP_OS_FAMILY || PHP_OS_FAMILY === 'Windows') { static::markTestSkipped(); } diff --git a/tests/unit/Unix/ConnectTest.php b/tests/unit/Unix/ConnectTest.php index 9960ce21..149114cb 100644 --- a/tests/unit/Unix/ConnectTest.php +++ b/tests/unit/Unix/ConnectTest.php @@ -10,10 +10,16 @@ use Psl\Str; use Psl\Unix; +use const PHP_OS_FAMILY; + final class ConnectTest extends TestCase { public function testConnect(): void { + if (PHP_OS_FAMILY === 'Windows') { + static::markTestSkipped('Unix Server is not supported on Windows platform.'); + } + $sock = Filesystem\create_temporary_file(prefix: 'psl-examples') . ".sock"; Async\concurrent([ @@ -29,7 +35,7 @@ public function testConnect(): void }, 'client' => static function () use ($sock): void { $client = Unix\connect($sock); - self::assertSame("unix://" . $sock, $client->getPeerAddress()->toString()); + self::assertSame("unix://" . $sock, $client->getPeerAddress()->toString()); $client->writeAll('Hello, World!'); $response = $client->readAll(); self::assertSame('!dlroW ,olleH', $response); diff --git a/tests/unit/Unix/ServerTest.php b/tests/unit/Unix/ServerTest.php index ca824d26..a17cdbc4 100644 --- a/tests/unit/Unix/ServerTest.php +++ b/tests/unit/Unix/ServerTest.php @@ -11,10 +11,16 @@ use Psl\Unix; use Throwable; +use const PHP_OS_FAMILY; + final class ServerTest extends TestCase { public function testNextConnectionOnStoppedServer(): void { + if (PHP_OS_FAMILY === 'Windows') { + static::markTestSkipped('Unix Server is not supported on Windows platform.'); + } + $sock = Filesystem\create_temporary_file(prefix: 'psl-examples') . ".sock"; $server = Unix\Server::create($sock); $server->stopListening(); @@ -27,6 +33,10 @@ public function testNextConnectionOnStoppedServer(): void public function testGetLocalAddressOnStoppedServer(): void { + if (PHP_OS_FAMILY === 'Windows') { + static::markTestSkipped('Unix Server is not supported on Windows platform.'); + } + $sock = Filesystem\create_temporary_file(prefix: 'psl-examples') . ".sock"; $server = Unix\Server::create($sock); $server->stopListening(); @@ -39,6 +49,10 @@ public function testGetLocalAddressOnStoppedServer(): void public function testThrowsForPendingOperation(): void { + if (PHP_OS_FAMILY === 'Windows') { + static::markTestSkipped('Unix Server is not supported on Windows platform.'); + } + $sock = Filesystem\create_temporary_file(prefix: 'psl-examples') . ".sock"; $server = Unix\Server::create($sock);