From e16144c2220dce6b535d52184e190d1ee1485bd0 Mon Sep 17 00:00:00 2001 From: azjezz Date: Sun, 24 Mar 2024 09:08:31 +0000 Subject: [PATCH] feat(tcp): support TLS/SSL Signed-off-by: azjezz --- composer.json | 1 + docs/README.md | 1 + docs/component/tcp-tls.md | 21 + docs/component/tcp.md | 2 +- docs/documenter.php | 1 + examples/channel/bounded.php | 5 +- examples/tcp/basic-http-client.php | 45 +- src/Psl/File/ReadHandle.php | 8 + src/Psl/File/ReadWriteHandle.php | 8 + src/Psl/IO/CloseReadStreamHandle.php | 8 + src/Psl/IO/CloseReadWriteStreamHandle.php | 8 + src/Psl/IO/CloseSeekReadStreamHandle.php | 8 + src/Psl/IO/CloseSeekReadWriteStreamHandle.php | 8 + src/Psl/IO/Internal/ResourceHandle.php | 86 ++- src/Psl/IO/MemoryHandle.php | 9 + .../IO/ReadHandleConvenienceMethodsTrait.php | 4 +- src/Psl/IO/ReadHandleInterface.php | 16 +- src/Psl/IO/ReadStreamHandle.php | 8 + src/Psl/IO/ReadWriteStreamHandle.php | 8 + src/Psl/IO/Reader.php | 79 ++- src/Psl/IO/SeekReadStreamHandle.php | 8 + src/Psl/IO/SeekReadWriteStreamHandle.php | 8 + src/Psl/Internal/Loader.php | 7 + src/Psl/Network/Internal/Socket.php | 8 + src/Psl/Network/Internal/socket_connect.php | 17 +- src/Psl/TCP/ConnectOptions.php | 77 ++- src/Psl/TCP/TLS/Certificate.php | 47 ++ src/Psl/TCP/TLS/ConnectOptions.php | 518 ++++++++++++++++++ .../TCP/TLS/Exception/ExceptionInterface.php | 11 + .../TLS/Exception/NegotiationException.php | 11 + src/Psl/TCP/TLS/HashingAlgorithm.php | 59 ++ src/Psl/TCP/TLS/SecurityLevel.php | 78 +++ src/Psl/TCP/TLS/Version.php | 62 +++ src/Psl/TCP/connect.php | 164 +++++- .../benchmark/Channel/CommunicationBench.php | 11 +- tests/unit/IO/ReaderTest.php | 18 +- .../MersenneTwisterPHPVariantSequenceTest.php | 25 +- 37 files changed, 1318 insertions(+), 145 deletions(-) create mode 100644 docs/component/tcp-tls.md create mode 100644 src/Psl/TCP/TLS/Certificate.php create mode 100644 src/Psl/TCP/TLS/ConnectOptions.php create mode 100644 src/Psl/TCP/TLS/Exception/ExceptionInterface.php create mode 100644 src/Psl/TCP/TLS/Exception/NegotiationException.php create mode 100644 src/Psl/TCP/TLS/HashingAlgorithm.php create mode 100644 src/Psl/TCP/TLS/SecurityLevel.php create mode 100644 src/Psl/TCP/TLS/Version.php diff --git a/composer.json b/composer.json index b2381c04..6740c895 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ "ext-mbstring": "*", "ext-sodium": "*", "ext-intl": "*", + "ext-openssl": "*", "revolt/event-loop": "^1.0.1" }, "require-dev": { diff --git a/docs/README.md b/docs/README.md index 2dc967f7..cf2367c5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,6 +52,7 @@ - [Psl\Str\Byte](./component/str-byte.md) - [Psl\Str\Grapheme](./component/str-grapheme.md) - [Psl\TCP](./component/tcp.md) +- [Psl\TCP\TLS](./component/tcp-tls.md) - [Psl\Trait](./component/trait.md) - [Psl\Type](./component/type.md) - [Psl\Unix](./component/unix.md) diff --git a/docs/component/tcp-tls.md b/docs/component/tcp-tls.md new file mode 100644 index 00000000..af517c16 --- /dev/null +++ b/docs/component/tcp-tls.md @@ -0,0 +1,21 @@ + + +[*index](./../README.md) + +--- + +### `Psl\TCP\TLS` Component + +#### `Classes` + +- [Certificate](./../../src/Psl/TCP/TLS/Certificate.php#L16) +- [ConnectOptions](./../../src/Psl/TCP/TLS/ConnectOptions.php#L34) +- [HashingAlgorithm](./../../src/Psl/TCP/TLS/HashingAlgorithm.php#L15) +- [SecurityLevel](./../../src/Psl/TCP/TLS/SecurityLevel.php#L18) +- [Version](./../../src/Psl/TCP/TLS/Version.php#L10) + + diff --git a/docs/component/tcp.md b/docs/component/tcp.md index 8c208bab..ef73d325 100644 --- a/docs/component/tcp.md +++ b/docs/component/tcp.md @@ -12,7 +12,7 @@ #### `Functions` -- [connect](./../../src/Psl/TCP/connect.php#L18) +- [connect](./../../src/Psl/TCP/connect.php#L37) #### `Classes` diff --git a/docs/documenter.php b/docs/documenter.php index 1fd92dd4..41df3ee1 100644 --- a/docs/documenter.php +++ b/docs/documenter.php @@ -222,6 +222,7 @@ function get_all_components(): array 'Psl\\Str\\Byte', 'Psl\\Str\\Grapheme', 'Psl\\TCP', + 'Psl\\TCP\\TLS', 'Psl\\Trait', 'Psl\\Type', 'Psl\\Unix', diff --git a/examples/channel/bounded.php b/examples/channel/bounded.php index 0bbcd95b..57a6be5f 100644 --- a/examples/channel/bounded.php +++ b/examples/channel/bounded.php @@ -30,9 +30,8 @@ for ($i = 0; $i < 10; $i++) { $file = File\open_read_only(__FILE__); - $reader = new IO\Reader($file); - while (!$reader->isEndOfFile()) { - $byte = $reader->readByte(); + while (!$file->reachedEndOfDataSource()) { + $byte = $file->readFixedSize(1); $sender->send($byte); } diff --git a/examples/tcp/basic-http-client.php b/examples/tcp/basic-http-client.php index cb877141..cd94c128 100644 --- a/examples/tcp/basic-http-client.php +++ b/examples/tcp/basic-http-client.php @@ -2,28 +2,45 @@ declare(strict_types=1); -namespace Psl\Example\TCP; - use Psl\Async; use Psl\IO; +use Psl\Str; use Psl\TCP; require __DIR__ . '/../../vendor/autoload.php'; -function fetch(string $host, string $path): string +Async\main(static function(): void { + [$headers, $content] = fetch('https://psalm.dev/'); + + $output = IO\error_handle() ?? IO\output_handle(); + + $output->writeAll($headers); + $output->writeAll("\n"); + $output->writeAll($content); +}); + +function fetch(string $url): array { - $client = TCP\connect($host, 80); - $client->writeAll("GET {$path} HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n"); - $response = $client->readAll(); - $client->close(); + $parsed_url = parse_url($url); + $host = $parsed_url['host']; + $port = $parsed_url['scheme'] === 'https' ? 443 : 80; + $path = $parsed_url['path'] ?? '/'; - return $response; -} + $options = TCP\ConnectOptions::create(); + if ($port === 443) { + $options = $options->withTLSConnectOptions( + TCP\TLS\ConnectOptions::default()->withPeerName($host), + ); + } -Async\main(static function (): int { - $response = fetch('example.com', '/'); + $client = TCP\connect($host, $port, $options); + $client->writeAll("GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n"); - IO\write_error_line($response); + $response = $client->readAll(); - return 0; -}); + $position = Str\search($response, "\r\n\r\n"); + $headers = Str\slice($response, 0, $position); + $content = Str\slice($response, $position + 4); + + return [$headers, $content]; +} diff --git a/src/Psl/File/ReadHandle.php b/src/Psl/File/ReadHandle.php index 14389c38..cfcaa83b 100644 --- a/src/Psl/File/ReadHandle.php +++ b/src/Psl/File/ReadHandle.php @@ -39,6 +39,14 @@ public function __construct(string $file) parent::__construct($this->readHandle); } + /** + * {@inheritDoc} + */ + public function reachedEndOfDataSource(): bool + { + return $this->readHandle->reachedEndOfDataSource(); + } + /** * {@inheritDoc} */ diff --git a/src/Psl/File/ReadWriteHandle.php b/src/Psl/File/ReadWriteHandle.php index a5aec5ba..0795c412 100644 --- a/src/Psl/File/ReadWriteHandle.php +++ b/src/Psl/File/ReadWriteHandle.php @@ -67,6 +67,14 @@ public function __construct(string $file, WriteMode $write_mode = WriteMode::Ope parent::__construct($this->readWriteHandle); } + /** + * {@inheritDoc} + */ + public function reachedEndOfDataSource(): bool + { + return $this->readWriteHandle->reachedEndOfDataSource(); + } + /** * {@inheritDoc} */ diff --git a/src/Psl/IO/CloseReadStreamHandle.php b/src/Psl/IO/CloseReadStreamHandle.php index 3bc288c6..3ec42f13 100644 --- a/src/Psl/IO/CloseReadStreamHandle.php +++ b/src/Psl/IO/CloseReadStreamHandle.php @@ -23,6 +23,14 @@ public function __construct(mixed $stream) $this->handle = new Internal\ResourceHandle($stream, read: true, write: false, seek: false, close: true); } + /** + * {@inheritDoc} + */ + public function reachedEndOfDataSource(): bool + { + return $this->handle->reachedEndOfDataSource(); + } + /** * {@inheritDoc} */ diff --git a/src/Psl/IO/CloseReadWriteStreamHandle.php b/src/Psl/IO/CloseReadWriteStreamHandle.php index 5ffce07c..70d3fc6c 100644 --- a/src/Psl/IO/CloseReadWriteStreamHandle.php +++ b/src/Psl/IO/CloseReadWriteStreamHandle.php @@ -24,6 +24,14 @@ public function __construct(mixed $stream) $this->handle = new Internal\ResourceHandle($stream, read: true, write: true, seek: false, close: true); } + /** + * {@inheritDoc} + */ + public function reachedEndOfDataSource(): bool + { + return $this->handle->reachedEndOfDataSource(); + } + /** * {@inheritDoc} */ diff --git a/src/Psl/IO/CloseSeekReadStreamHandle.php b/src/Psl/IO/CloseSeekReadStreamHandle.php index ced72ded..fe1269b8 100644 --- a/src/Psl/IO/CloseSeekReadStreamHandle.php +++ b/src/Psl/IO/CloseSeekReadStreamHandle.php @@ -23,6 +23,14 @@ public function __construct(mixed $stream) $this->handle = new Internal\ResourceHandle($stream, read: true, write: false, seek: true, close: true); } + /** + * {@inheritDoc} + */ + public function reachedEndOfDataSource(): bool + { + return $this->handle->reachedEndOfDataSource(); + } + /** * {@inheritDoc} */ diff --git a/src/Psl/IO/CloseSeekReadWriteStreamHandle.php b/src/Psl/IO/CloseSeekReadWriteStreamHandle.php index ef665ce7..38928c26 100644 --- a/src/Psl/IO/CloseSeekReadWriteStreamHandle.php +++ b/src/Psl/IO/CloseSeekReadWriteStreamHandle.php @@ -24,6 +24,14 @@ public function __construct(mixed $stream) $this->handle = new Internal\ResourceHandle($stream, read: true, write: true, seek: true, close: true); } + /** + * {@inheritDoc} + */ + public function reachedEndOfDataSource(): bool + { + return $this->handle->reachedEndOfDataSource(); + } + /** * {@inheritDoc} */ diff --git a/src/Psl/IO/Internal/ResourceHandle.php b/src/Psl/IO/Internal/ResourceHandle.php index ca908ec7..af76af3a 100644 --- a/src/Psl/IO/Internal/ResourceHandle.php +++ b/src/Psl/IO/Internal/ResourceHandle.php @@ -14,15 +14,19 @@ use function error_get_last; use function fclose; +use function feof; +use function fread; use function fseek; use function ftell; use function fwrite; use function is_resource; use function max; use function str_contains; +use function stream_get_contents; use function stream_get_meta_data; use function stream_set_blocking; use function stream_set_read_buffer; +use function stream_set_write_buffer; use function substr; /** @@ -58,6 +62,8 @@ class ResourceHandle implements IO\CloseSeekReadWriteStreamHandleInterface */ private string $readWatcher = 'invalid'; + private bool $reachedEof = false; + /** * @var null|Async\Sequence> */ @@ -70,30 +76,38 @@ class ResourceHandle implements IO\CloseSeekReadWriteStreamHandleInterface */ private string $writeWatcher = 'invalid'; + private bool $useSingleRead = false; + /** * @param resource $stream */ - public function __construct(mixed $stream, bool $read, bool $write, bool $seek, private bool $close) + public function __construct(mixed $stream, bool $read, bool $write, bool $seek, private readonly bool $close) { /** @psalm-suppress RedundantConditionGivenDocblockType - The stream is always a resource, but we want to make sure it is a stream resource. */ $this->stream = Type\resource('stream')->assert($stream); - /** @psalm-suppress UnusedFunctionCall */ - stream_set_read_buffer($stream, 0); stream_set_blocking($stream, false); $meta = stream_get_meta_data($stream); + if ($read) { + $this->useSingleRead = ($meta["stream_type"] === "udp_socket" || $meta["stream_type"] === "STDIO"); + } + $blocks = $meta['blocked'] || ($meta['wrapper_type'] ?? '') === 'plainfile'; if ($seek) { Psl\invariant($meta['seekable'], 'Handle is not seekable.'); } if ($read) { + /** @psalm-suppress UnusedFunctionCall */ + stream_set_read_buffer($stream, 0); + Psl\invariant(str_contains($meta['mode'], 'r') || str_contains($meta['mode'], '+'), 'Handle is not readable.'); $this->readWatcher = EventLoop::onReadable($this->stream, function () { - $this->readSuspension?->resume(null); + $this->readSuspension?->resume(); }); + $this->readSequence = new Async\Sequence( /** * @param array{null|int<1, max>, null|float} $input @@ -136,6 +150,8 @@ function (array $input) use ($blocks): string { } if ($write) { + stream_set_write_buffer($stream, 0); + $writable = str_contains($meta['mode'], 'x') || str_contains($meta['mode'], 'w') || str_contains($meta['mode'], 'c') @@ -144,9 +160,10 @@ function (array $input) use ($blocks): string { Psl\invariant($writable, 'Handle is not writeable.'); - $this->writeWatcher = EventLoop::onReadable($this->stream, function () { - $this->writeSuspension?->resume(null); + $this->writeWatcher = EventLoop::onWritable($this->stream, function () { + $this->writeSuspension?->resume(); }); + $this->writeSequence = new Async\Sequence( /** * @param array{string, null|float} $input @@ -254,6 +271,18 @@ public function tell(): int return max($result, 0); } + /** + * {@inheritDoc} + */ + public function reachedEndOfDataSource(): bool + { + if (!is_resource($this->stream)) { + throw new Exception\AlreadyClosedException('Handle has already been closed.'); + } + + return $this->reachedEof; + } + /** * {@inheritDoc} */ @@ -279,7 +308,13 @@ public function tryRead(?int $max_bytes = null): string $max_bytes = self::MAXIMUM_READ_BUFFER_SIZE; } - $result = fread($this->stream, $max_bytes); + + if ($this->useSingleRead) { + $result = fread($this->stream, $max_bytes); + } else { + $result = stream_get_contents($this->stream, $max_bytes); + } + if ($result === false) { /** @var array{message: string} $error */ $error = error_get_last(); @@ -287,6 +322,10 @@ public function tryRead(?int $max_bytes = null): string throw new Exception\RuntimeException($error['message'] ?? 'unknown error.'); } + if ($result === '' && feof($this->stream)) { + $this->reachedEof = true; + } + return $result; } @@ -311,31 +350,30 @@ public function close(): void { EventLoop::cancel($this->readWatcher); EventLoop::cancel($this->writeWatcher); - if (null !== $this->stream) { + + // don't close the stream if `$this->close` is false, or if it's already closed. + if ($this->close && is_resource($this->stream)) { $exception = new Exception\AlreadyClosedException('Handle has already been closed.'); $this->readSequence?->cancel($exception); - $this->readSuspension?->throw($exception); - $this->writeSequence?->cancel($exception); + + $this->readSuspension?->throw($exception); $this->writeSuspension?->throw($exception); - // don't close the stream if `$this->close` is false, or if it's already closed. - if ($this->close && is_resource($this->stream)) { - $stream = $this->stream; - $this->stream = null; - $result = @fclose($stream); - if ($result === false) { - /** @var array{message: string} $error */ - $error = error_get_last(); + $stream = $this->stream; + $this->stream = null; + $result = @fclose($stream); + if ($result === false) { + /** @var array{message: string} $error */ + $error = error_get_last(); - throw new Exception\RuntimeException($error['message'] ?? 'unknown error.'); - } - } else { - // Stream could be set to a non-null closed-resource, - // if manually closed using `fclose($handle->getStream)`. - $this->stream = null; + throw new Exception\RuntimeException($error['message'] ?? 'unknown error.'); } + } else { + // Stream could be set to a non-null closed-resource, + // if manually closed using `fclose($handle->getStream)`. + $this->stream = null; } } } diff --git a/src/Psl/IO/MemoryHandle.php b/src/Psl/IO/MemoryHandle.php index 4aa4c7c5..8baafc80 100644 --- a/src/Psl/IO/MemoryHandle.php +++ b/src/Psl/IO/MemoryHandle.php @@ -27,6 +27,15 @@ public function __construct(string $buffer = '') $this->buffer = $buffer; } + public function reachedEndOfDataSource(): bool + { + $this->assertHandleIsOpen(); + + $length = strlen($this->buffer); + + return $this->offset >= $length; + } + /** * Read from the handle. * diff --git a/src/Psl/IO/ReadHandleConvenienceMethodsTrait.php b/src/Psl/IO/ReadHandleConvenienceMethodsTrait.php index ad3d7d21..53216ef2 100644 --- a/src/Psl/IO/ReadHandleConvenienceMethodsTrait.php +++ b/src/Psl/IO/ReadHandleConvenienceMethodsTrait.php @@ -17,7 +17,7 @@ trait ReadHandleConvenienceMethodsTrait /** * Read until there is no more data to read. * - * It is possible for this to never return, e.g. if called on a pipe or + * It is possible for this to never return, e.g. if called on a pipe * or socket which the other end keeps open forever. Set a timeout if you * do not want this to happen. * @@ -60,7 +60,7 @@ static function () use ($data): void { if ($to_read !== null) { $to_read -= strlen($chunk); } - } while (($to_read === null || $to_read > 0) && $chunk !== ''); + } while (($to_read === null || $to_read > 0) && !$this->reachedEndOfDataSource()); return $data->value; } diff --git a/src/Psl/IO/ReadHandleInterface.php b/src/Psl/IO/ReadHandleInterface.php index 801d5804..7b31e2eb 100644 --- a/src/Psl/IO/ReadHandleInterface.php +++ b/src/Psl/IO/ReadHandleInterface.php @@ -9,6 +9,16 @@ */ interface ReadHandleInterface extends HandleInterface { + /** + * Indicates whether the cursor is at the end of the data source ( EOF ). + * + * @throws Exception\RuntimeException If an error occurred during the operation. + * @throws Exception\AlreadyClosedException If the handle has been already closed. + * + * @return bool true if the cursor is at the end of the data source, otherwise false. + */ + public function reachedEndOfDataSource(): bool; + /** * Try to read from the handle immediately, without waiting. * @@ -20,7 +30,7 @@ interface ReadHandleInterface extends HandleInterface * @throws Exception\RuntimeException If an error occurred during the operation. * @throws Exception\AlreadyClosedException If the handle has been already closed. * - * @return string the read data on success, or an empty string if the handle is not ready for read, or the end of file is reached. + * @return string the read data on success, or an empty string if the handle is not ready for read, or the end of data source is reached. * * @see ReadStreamHandleInterface::read() * @see ReadStreamHandleInterface::readAll() @@ -36,7 +46,7 @@ public function tryRead(?int $max_bytes = null): string; * @throws Exception\RuntimeException If an error occurred during the operation. * @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle. * - * @return string the read data on success, or an empty string if the end of file is reached. + * @return string the read data on success, or an empty string if the end of data source is reached. * * Up to `$max_bytes` may be allocated in a buffer; large values may lead to * unnecessarily hitting the request memory limit. @@ -64,7 +74,7 @@ public function readAll(?int $max_bytes = null, ?float $timeout = null): string; /** * Read a fixed amount of data. * - * It is possible for this to never return, e.g. if called on a pipe or + * It is possible for this to never return, e.g. if called on a pipe * or socket which the other end keeps open forever. Set a timeout if you * do not want this to happen. * diff --git a/src/Psl/IO/ReadStreamHandle.php b/src/Psl/IO/ReadStreamHandle.php index ad7269bc..6c71c877 100644 --- a/src/Psl/IO/ReadStreamHandle.php +++ b/src/Psl/IO/ReadStreamHandle.php @@ -23,6 +23,14 @@ public function __construct(mixed $stream) $this->handle = new Internal\ResourceHandle($stream, read: true, write: false, seek: false, close: false); } + /** + * {@inheritDoc} + */ + public function reachedEndOfDataSource(): bool + { + return $this->handle->reachedEndOfDataSource(); + } + /** * {@inheritDoc} */ diff --git a/src/Psl/IO/ReadWriteStreamHandle.php b/src/Psl/IO/ReadWriteStreamHandle.php index 3fae5178..fb4a67ba 100644 --- a/src/Psl/IO/ReadWriteStreamHandle.php +++ b/src/Psl/IO/ReadWriteStreamHandle.php @@ -24,6 +24,14 @@ public function __construct(mixed $stream) $this->handle = new Internal\ResourceHandle($stream, read: true, write: true, seek: false, close: false); } + /** + * {@inheritDoc} + */ + public function reachedEndOfDataSource(): bool + { + return $this->handle->reachedEndOfDataSource(); + } + /** * {@inheritDoc} */ diff --git a/src/Psl/IO/Reader.php b/src/Psl/IO/Reader.php index 11ec1462..2d801408 100644 --- a/src/Psl/IO/Reader.php +++ b/src/Psl/IO/Reader.php @@ -16,7 +16,7 @@ final class Reader implements ReadHandleInterface { use ReadHandleConvenienceMethodsTrait; - private ReadHandleInterface $handle; + private readonly ReadHandleInterface $handle; private bool $eof = false; private string $buffer = ''; @@ -26,6 +26,33 @@ public function __construct(ReadHandleInterface $handle) $this->handle = $handle; } + /** + * {@inheritDoc} + */ + public function reachedEndOfDataSource(): bool + { + if ($this->eof) { + return true; + } + + if ($this->buffer !== '') { + return false; + } + + // @codeCoverageIgnoreStart + try { + $this->buffer = $this->handle->read(); + if ($this->buffer === '') { + return $this->eof = $this->handle->reachedEndOfDataSource(); + } + } catch (Exception\ExceptionInterface) { + // ignore; it'll be thrown again when attempting a real read. + } + // @codeCoverageIgnoreEnd + + return false; + } + /** * {@inheritDoc} */ @@ -65,23 +92,6 @@ function (): void { return $ret; } - /** - * @param null|positive-int $desired_bytes - * - * @throws Exception\AlreadyClosedException If the handle has been already closed. - * @throws Exception\RuntimeException If an error occurred during the operation. - * @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle. - */ - private function fillBuffer(?int $desired_bytes, ?float $timeout): void - { - $chunk = $this->handle->read($desired_bytes, $timeout); - if ($chunk === '') { - $this->eof = true; - } - - $this->buffer .= $chunk; - } - /** * Read a single byte from the handle. * @@ -102,7 +112,6 @@ public function readByte(?float $timeout = null): string $ret = $this->buffer[0]; if ($ret === $this->buffer) { $this->buffer = ''; - $this->eof = true; return $ret; } @@ -246,33 +255,17 @@ public function getHandle(): ReadHandleInterface } /** - * @throws Exception\RuntimeException If an error occurred during the operation. - * @throws Exception\AlreadyClosedException If the handle has been already closed. + * @param null|positive-int $desired_bytes * - * @return bool true if EOL has been reached, false otherwise. + * @throws Exception\AlreadyClosedException If the handle has been already closed. + * @throws Exception\RuntimeException If an error occurred during the operation. + * @throws Exception\TimeoutException If $timeout is reached before being able to read from the handle. */ - public function isEndOfFile(): bool + private function fillBuffer(?int $desired_bytes, ?float $timeout): void { - if ($this->eof) { - return true; - } - - if ($this->buffer !== '') { - return false; - } - - // @codeCoverageIgnoreStart - try { - $this->buffer = $this->handle->read(); - if ($this->buffer === '') { - $this->eof = true; - return true; - } - } catch (Exception\ExceptionInterface) { - // ignore; it'll be thrown again when attempting a real read. + $this->buffer .= $chunk = $this->handle->read($desired_bytes, $timeout); + if ($chunk === '') { + $this->eof = $this->handle->reachedEndOfDataSource(); } - // @codeCoverageIgnoreEnd - - return false; } } diff --git a/src/Psl/IO/SeekReadStreamHandle.php b/src/Psl/IO/SeekReadStreamHandle.php index aa4d7e02..ca74f2e3 100644 --- a/src/Psl/IO/SeekReadStreamHandle.php +++ b/src/Psl/IO/SeekReadStreamHandle.php @@ -23,6 +23,14 @@ public function __construct(mixed $stream) $this->handle = new Internal\ResourceHandle($stream, read: true, write: false, seek: true, close: false); } + /** + * {@inheritDoc} + */ + public function reachedEndOfDataSource(): bool + { + return $this->handle->reachedEndOfDataSource(); + } + /** * {@inheritDoc} */ diff --git a/src/Psl/IO/SeekReadWriteStreamHandle.php b/src/Psl/IO/SeekReadWriteStreamHandle.php index 260edcbf..ea78e8ce 100644 --- a/src/Psl/IO/SeekReadWriteStreamHandle.php +++ b/src/Psl/IO/SeekReadWriteStreamHandle.php @@ -24,6 +24,14 @@ public function __construct(mixed $stream) $this->handle = new Internal\ResourceHandle($stream, read: true, write: true, seek: true, close: false); } + /** + * {@inheritDoc} + */ + public function reachedEndOfDataSource(): bool + { + return $this->handle->reachedEndOfDataSource(); + } + /** * {@inheritDoc} */ diff --git a/src/Psl/Internal/Loader.php b/src/Psl/Internal/Loader.php index 8c384e2b..22065b86 100644 --- a/src/Psl/Internal/Loader.php +++ b/src/Psl/Internal/Loader.php @@ -612,6 +612,7 @@ final class Loader 'Psl\\Range\\LowerBoundRangeInterface' => 'Psl/Range/LowerBoundRangeInterface.php', 'Psl\\Range\\UpperBoundRangeInterface' => 'Psl/Range/UpperBoundRangeInterface.php', 'Psl\\Default\\DefaultInterface' => 'Psl/Default/DefaultInterface.php', + 'Psl\\TCP\\TLS\\Exception\\ExceptionInterface' => 'Psl/TCP/TLS/Exception/ExceptionInterface.php', ]; public const TRAITS = [ @@ -815,6 +816,12 @@ final class Loader 'Psl\\Range\\ToRange' => 'Psl/Range/ToRange.php', 'Psl\\Range\\BetweenRange' => 'Psl/Range/BetweenRange.php', 'Psl\\Range\\FullRange' => 'Psl/Range/FullRange.php', + 'Psl\\TCP\\TLS\\Exception\\NegotiationException' => 'Psl/TCP/TLS/Exception/NegotiationException.php', + 'Psl\\TCP\\TLS\\Certificate' => 'Psl/TCP/TLS/Certificate.php', + 'Psl\\TCP\\TLS\\ConnectOptions' => 'Psl/TCP/TLS/ConnectOptions.php', + 'Psl\\TCP\\TLS\\HashingAlgorithm' => 'Psl/TCP/TLS/HashingAlgorithm.php', + 'Psl\\TCP\\TLS\\SecurityLevel' => 'Psl/TCP/TLS/SecurityLevel.php', + 'Psl\\TCP\\TLS\\Version' => 'Psl/TCP/TLS/Version.php', ]; public const ENUMS = [ diff --git a/src/Psl/Network/Internal/Socket.php b/src/Psl/Network/Internal/Socket.php index 489735af..25f61908 100644 --- a/src/Psl/Network/Internal/Socket.php +++ b/src/Psl/Network/Internal/Socket.php @@ -32,6 +32,14 @@ public function __construct($stream) $this->handle = new Internal\ResourceHandle($stream, read: true, write: true, seek: false, close: true); } + /** + * {@inheritDoc} + */ + public function reachedEndOfDataSource(): bool + { + return $this->handle->reachedEndOfDataSource(); + } + /** * {@inheritDoc} */ diff --git a/src/Psl/Network/Internal/socket_connect.php b/src/Psl/Network/Internal/socket_connect.php index 50420a27..b3dd7c8c 100644 --- a/src/Psl/Network/Internal/socket_connect.php +++ b/src/Psl/Network/Internal/socket_connect.php @@ -6,12 +6,14 @@ use Psl\Internal; use Psl\Network\Exception; +use Psl\Str; use Revolt\EventLoop; use function fclose; use function is_resource; use function stream_context_create; use function stream_socket_client; +use function stream_socket_get_name; use const STREAM_CLIENT_ASYNC_CONNECT; use const STREAM_CLIENT_CONNECT; @@ -61,8 +63,19 @@ function socket_connect(string $uri, array $context = [], ?float $timeout = null }); try { - /** @var resource */ - return $suspension->suspend(); + /** @var resource $socket */ + $socket = $suspension->suspend(); + + if (stream_socket_get_name($socket, true) === false) { + fclose($socket); + + throw new Exception\RuntimeException(Str\format( + 'Connection to %s refused%s', + $uri, + )); + } + + return $socket; } finally { EventLoop::cancel($write_watcher); EventLoop::cancel($timeout_watcher); diff --git a/src/Psl/TCP/ConnectOptions.php b/src/Psl/TCP/ConnectOptions.php index 98da8263..2537d598 100644 --- a/src/Psl/TCP/ConnectOptions.php +++ b/src/Psl/TCP/ConnectOptions.php @@ -14,16 +14,18 @@ final class ConnectOptions implements DefaultInterface { /** - * Initializes a new instance of {@see ConnectOptions} with the specified settings. + * Constructor is private to enforce immutability. Use static creation methods instead. * - * @param bool $noDelay Determines whether the TCP_NODELAY option is enabled, controlling - * the use of the Nagle algorithm. When true, TCP_NODELAY is enabled, - * and the Nagle algorithm is disabled. + * @param bool $noDelay Indicates whether to disable Nagle's algorithm. When true, packets are sent immediately. + * @param null|array{non-empty-string, null|int} $bindTo Specifies the IP address and optionally the port to bind to. + * Format: [`IP`, `Port`]. `Port` is optional and can be null. * * @pure */ - public function __construct( - public readonly bool $noDelay, + private function __construct( + public readonly bool $noDelay, + public readonly ?array $bindTo, + public readonly ?TLS\ConnectOptions $TLSConnectOptions, ) { } @@ -41,18 +43,12 @@ public function __construct( */ public static function create(bool $noDelay = false): ConnectOptions { - return new self($noDelay); + return new self($noDelay, null, null); } /** * Creates and returns a default instance of {@see ConnectOptions}. * - * The default instance has the TCP_NODELAY option disabled, allowing the Nagle algorithm - * to be used. This method is a convenience wrapper around the `create` method, adhering to - * the {@see DefaultInterface} contract. - * - * @return static A default ConnectOptions instance with noDelay set to false. - * * @pure */ public static function default(): static @@ -61,14 +57,63 @@ public static function default(): static } /** - * Returns a new instance of {@see ConnectOptions} with the noDelay setting modified. + * Returns a new instance with noDelay enabled. * - * @param bool $enabled Specifies the desired state of the TCP_NODELAY option. + * @return ConnectOptions A new instance with noDelay set to true. * * @mutation-free */ public function withNoDelay(bool $enabled = true): ConnectOptions { - return new self($enabled); + return new self($enabled, $this->bindTo, $this->TLSConnectOptions); + } + + /** + * Returns a new instance with the specified IP and optionally port to bind to. + * + * @param non-empty-string $ip The IP address to bind the connection to. + * @param int|null $port The port number to bind the connection to, or null to not specify. + * + * @return ConnectOptions A new instance with the updated bindTo option. + * + * @mutation-free + */ + public function withBindTo(string $ip, ?int $port = null): ConnectOptions + { + return new self($this->noDelay, [$ip, $port], $this->TLSConnectOptions); + } + + /** + * Returns a new instance without any bindTo configuration. + * + * @return ConnectOptions A new instance with bindTo set to null. + * + * @mutation-free + */ + public function withoutBindTo(): ConnectOptions + { + return new self($this->noDelay, null, $this->TLSConnectOptions); + } + + /** + * Returns a new instance with the specified TLS connect options. + * + * @param TLS\ConnectOptions $tls_connect_options The TLS connect options. + * + * @mutation-free + */ + public function withTLSConnectOptions(TLS\ConnectOptions $tls_connect_options): ConnectOptions + { + return new self($this->noDelay, $this->bindTo, $tls_connect_options); + } + + /** + * Returns a new instance without the Tls connect options. + * + * @mutation-free + */ + public function withoutTlsConnectOptions(): ConnectOptions + { + return new self($this->noDelay, $this->bindTo, null); } } diff --git a/src/Psl/TCP/TLS/Certificate.php b/src/Psl/TCP/TLS/Certificate.php new file mode 100644 index 00000000..bb66f0ff --- /dev/null +++ b/src/Psl/TCP/TLS/Certificate.php @@ -0,0 +1,47 @@ +withMinimumVersion(TCP\TLS\Version::Tls12) + * ->withVerifyPeer(true) + * ->withCertificateAuthorityFile('/path/to/cafile.pem'); + * + * ``` + * + * @immutable The class is designed to be immutable, ensuring that an instance once created cannot be modified. + */ +final class ConnectOptions implements DefaultInterface +{ + /** + * Constructs a new instance of the TLS connection options with specified settings. + * + * @param Version $minimumVersion Specifies the minimum TLS version that is acceptable for connections. + * @param string $peerName Specifies the expected name of the peer, used in verifying the peer's certificate. + * @param bool $peerVerification Indicates whether the peer's SSL certificate should be verified. + * @param int<0, max> $verificationDepth Specifies the maximum depth for certificate chain verification. + * @param null|non-empty-list $peerFingerprints Optional. Specifies peer fingerprints for certificate verification, allowing + * for additional security checks based on expected certificate fingerprints. + * @param null|non-empty-string $ciphers Specifies the cipher suite(s) to be used for the TLS connection, determining + * the encryption algorithms that will be available during the TLS handshake. + * @param null|non-empty-string $certificateAuthorityFile Optional. Specifies the path to a Certificate Authority (CA) file + * to be used for verifying the peer's certificate. + * @param null|non-empty-string $certificateAuthorityPath Optional. Specifies the path to a directory containing Certificate + * Authority (CA) certificates, which will be used for verifying the peer's certificate. + * @param bool $capturePeerCertificate Indicates whether the peer's certificate should be captured during + * the handshake process. This can be useful for inspection or logging purposes. + * @param bool $SNIEnabled Indicates whether Server Name Indication (SNI) should be used, + * which allows multiple domains to be served over HTTPS from the same IP address. + * @param SecurityLevel $securityLevel Specifies the security level for the TLS connection, influencing + * the choice of cryptographic algorithms. + * @param null|Certificate $certificate Optional. Specifies a client certificate to be used for the TLS connection, + * which may be required by servers expecting client authentication. + * @param list $ALPNProtocols Specifies the protocols to be used for Application Layer Protocol Negotiation (ALPN), + * enabling the selection of application-specific protocols within the TLS layer. + * + * @pure + */ + private function __construct( + public readonly Version $minimumVersion, + public readonly string $peerName, + public readonly bool $peerVerification, + public readonly int $verificationDepth, + public readonly ?array $peerFingerprints, + public readonly ?string $ciphers, + public readonly ?string $certificateAuthorityFile, + public readonly ?string $certificateAuthorityPath, + public readonly bool $capturePeerCertificate, + public readonly bool $SNIEnabled, + public readonly SecurityLevel $securityLevel, + public readonly ?Certificate $certificate, + public readonly array $ALPNProtocols, + ) { + } + + /** + * Creates a new instance of ConnectOptions with default settings. + * + * @return ConnectOptions The new instance with default values. + * + * @pure + */ + public static function create(): self + { + return new self( + minimumVersion: Version::default(), + peerName: '', + peerVerification: true, + verificationDepth: 10, + peerFingerprints: null, + ciphers: null, + certificateAuthorityFile: null, + certificateAuthorityPath: null, + capturePeerCertificate: false, + SNIEnabled: true, + securityLevel: SecurityLevel::default(), + certificate: null, + ALPNProtocols: [] + ); + } + + /** + * Creates and returns a default instance of {@see ConnectOptions}. + * + * @pure + */ + public static function default(): static + { + return static::create(); + } + + /** + * Specifies the minimum version of the TLS protocol that is acceptable. + * + * @param Version $version The minimum TLS version. + * + * @return ConnectOptions A new instance with the specified minimum TLS version. + * + * @mutation-free + */ + public function withMinimumVersion(Version $version): self + { + return new self( + $version, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Sets the expected name of the peer for certificate verification. + * + * @param string $peer_name The expected name of the peer. + * + * @return ConnectOptions A new instance with the specified peer name. + * + * @mutation-free + */ + public function withPeerName(string $peer_name): self + { + return new self( + $this->minimumVersion, + $peer_name, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Enables verification of the peer's SSL certificate. + * + * @return ConnectOptions A new instance with peer verification enabled. + * + * @mutation-free + */ + public function withPeerVerification(bool $peer_verification = true): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $peer_verification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Sets the maximum depth for certificate chain verification. + * + * @param int<0, max> $verification_depth The maximum verification depth. + * + * @return ConnectOptions A new instance with the specified verification depth. + * + * @mutation-free + */ + public function withVerificationDepth(int $verification_depth): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $verification_depth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + + /** + * Adds a peer fingerprint for certificate verification. + * + * @param HashingAlgorithm $hashing_algorithm The hashing algorithm used for the fingerprint. + * @param string $fingerprint The fingerprint string. + * + * @return ConnectOptions A new instance with the added peer fingerprint. + * + * @mutation-free + */ + public function withPeerFingerprint(HashingAlgorithm $hashing_algorithm, string $fingerprint): self + { + return $this->withPeerFingerprints([ + [$hashing_algorithm, $fingerprint], + ]); + } + + /** + * Sets multiple peer fingerprints for certificate verification. + * + * @param null|non-empty-list $peer_fingerprints An array of peer fingerprints. + * + * @return ConnectOptions A new instance with the specified peer fingerprints. + * + * @mutation-free + */ + public function withPeerFingerprints(?array $peer_fingerprints): self + { + if (null !== $peer_fingerprints) { + foreach ($peer_fingerprints as [$algorithm, $fingerprint]) { + Psl\invariant( + Byte\length($fingerprint) === $algorithm->getExpectedLength(), + 'Fingerprint length does not match expected length for "%s" algorithm.', + $algorithm->value, + ); + } + } + + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $peer_fingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Removes all peer fingerprints from the certificate verification process. + * + * @return ConnectOptions A new instance without any peer fingerprints. + * + * @mutation-free + */ + public function withoutPeerFingerprints(): self + { + return $this->withPeerFingerprints(null); + } + + /** + * Specifies the cipher suite to be used for the TLS connection. + * + * @param non-empty-string $ciphers The cipher suite. + * + * @return ConnectOptions A new instance with the specified ciphers. + * + * @mutation-free + */ + public function withCiphers(string $ciphers): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Sets the path to the Certificate Authority (CA) file for verifying the peer certificate. + * + * @param null|non-empty-string $certificate_authority_file The path to the CA file. + * + * @return ConnectOptions A new instance with the specified CA file path. + * + * @mutation-free + */ + public function withCertificateAuthorityFile(?string $certificate_authority_file): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $certificate_authority_file, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Sets the path to the Certificate Authority (CA) directory for verifying the peer certificate. + * + * @param null|non-empty-string $certificate_authority_path The path to the CA directory. + * + * @return ConnectOptions A new instance with the specified CA directory path. + * + * @mutation-free + */ + public function withCertificateAuthorityPath(?string $certificate_authority_path): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $certificate_authority_path, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Enables or disables capturing of the peer's certificate. + * + * @param bool $capture_peer_certificate Whether to capture the peer's certificate. + * + * @return ConnectOptions A new instance with the specified peer certificate capturing setting. + * + * @mutation-free + */ + public function withCapturePeerCertificate(bool $capture_peer_certificate): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $capture_peer_certificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Enables or disables Server Name Indication (SNI). + * + * @param bool $sni_enabled Whether SNI is enabled. + * + * @return ConnectOptions A new instance with the specified SNI setting. + * + * @mutation-free + */ + public function withSNIEnabled(bool $sni_enabled = true): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $sni_enabled, + $this->securityLevel, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Sets the security level for the TLS connection. + * + * @param SecurityLevel $security_level The security level. + * + * @return ConnectOptions A new instance with the specified security level. + * + * @mutation-free + */ + public function withSecurityLevel(SecurityLevel $security_level): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $security_level, + $this->certificate, + $this->ALPNProtocols + ); + } + + /** + * Specifies a Certificate to be used for the TLS connection. + * + * @param null|Certificate $certificate The certificate. + * + * @return ConnectOptions A new instance with the specified certificate. + * + * @mutation-free + */ + public function withCertificate(?Certificate $certificate): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $certificate, + $this->ALPNProtocols + ); + } + + /** + * Sets the protocols to be used for Application Layer Protocol Negotiation (ALPN). + * + * @param list $alpn_protocols The ALPN protocols. + * + * @return ConnectOptions A new instance with the specified ALPN protocols. + * + * @mutation-free + */ + public function withALPNProtocols(array $alpn_protocols): self + { + return new self( + $this->minimumVersion, + $this->peerName, + $this->peerVerification, + $this->verificationDepth, + $this->peerFingerprints, + $this->ciphers, + $this->certificateAuthorityFile, + $this->certificateAuthorityPath, + $this->capturePeerCertificate, + $this->SNIEnabled, + $this->securityLevel, + $this->certificate, + $alpn_protocols + ); + } +} diff --git a/src/Psl/TCP/TLS/Exception/ExceptionInterface.php b/src/Psl/TCP/TLS/Exception/ExceptionInterface.php new file mode 100644 index 00000000..65940c60 --- /dev/null +++ b/src/Psl/TCP/TLS/Exception/ExceptionInterface.php @@ -0,0 +1,11 @@ + 40, + self::Sha256 => 64, + }; + } +} diff --git a/src/Psl/TCP/TLS/SecurityLevel.php b/src/Psl/TCP/TLS/SecurityLevel.php new file mode 100644 index 00000000..a04306b5 --- /dev/null +++ b/src/Psl/TCP/TLS/SecurityLevel.php @@ -0,0 +1,78 @@ + [ 'tcp_nodelay' => $options->noDelay, - ] + ], ]; - $socket = Network\Internal\socket_connect("tcp://{$host}:{$port}", $context, $timeout); + if (null !== $options->bindTo) { + $context['socket']['bindto'] = $options->bindTo[0] . ':' . ((string) ($options->bindTo[1] ?? 0)); + } + + $socket = Network\Internal\socket_connect("tcp://$host:$port", $context, $optional_timeout->getRemaining()); + $tls_options = $options->TLSConnectOptions; + if (null !== $tls_options) { + if ($tls_options->peerName === '') { + $tls_options = $tls_options->withPeerName($host); + } + + $context = [ + 'crypto_method' => match ($tls_options->minimumVersion) { + TLS\Version::Tls10 => STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, + TLS\Version::Tls11 => STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, + TLS\Version::Tls12 => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, + TLS\Version::Tls13 => STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT, + }, + 'peer_name' => $tls_options->peerName, + 'verify_peer' => $tls_options->peerVerification, + 'verify_peer_name' => $tls_options->peerVerification, + 'verify_depth' => $tls_options->verificationDepth, + 'ciphers' => $tls_options->ciphers ?? OPENSSL_DEFAULT_STREAM_CIPHERS, + 'capture_peer_cert' => $tls_options->capturePeerCertificate, + 'capture_peer_cert_chain' => $tls_options->capturePeerCertificate, + 'SNI_enabled' => $tls_options->SNIEnabled, + 'security_level' => $tls_options->securityLevel->value, + ]; + + if (null !== $tls_options->certificate) { + $context['local_cert'] = $tls_options->certificate->certificateFile; + if ($tls_options->certificate->certificateFile !== $tls_options->certificate->keyFile) { + $context['local_pk'] = $tls_options->certificate->keyFile; + } + + if ($tls_options->certificate->passphrase !== null) { + $context['passphrase'] = $tls_options->certificate->passphrase; + } + } + + if ($tls_options->certificateAuthorityFile !== null) { + $context['cafile'] = $tls_options->certificateAuthorityFile; + } + + if ($tls_options->certificateAuthorityPath !== null) { + $context['capath'] = $tls_options->certificateAuthorityPath; + } + + if ([] !== $tls_options->ALPNProtocols) { + $context['alpn_protocols'] = Str\join($tls_options->ALPNProtocols, ','); + } + + if ($tls_options->peerFingerprints !== null) { + $peer_fingerprints = []; + foreach ($tls_options->peerFingerprints as $peer_fingerprint) { + $peer_fingerprints[$peer_fingerprint[0]->value] = $peer_fingerprint[1]; + } + + $context['peer_fingerprint'] = $peer_fingerprints; + } + + if (PHP_VERSION_ID >= 80300) { + /** @psalm-suppress UnusedFunctionCall */ + stream_context_set_options($socket, ['ssl' => $context]); + } else { + stream_context_set_option($socket, ['ssl' => $context]); + } + + + $error_handler = static function (int $code, string $message) use ($socket): never { + if (feof($socket)) { + $message = 'Connection reset by peer'; + } + + throw new NegotiationException('TLS negotiation failed: ' . $message); + }; + + try { + set_error_handler($error_handler); + $result = stream_socket_enable_crypto($socket, enable: true); + + if ($result === false) { + throw new NegotiationException('TLS negotiation failed: Unknown error'); + } + } finally { + restore_error_handler(); + } + + if (true !== $result) { + while (true) { + $suspension = EventLoop::getSuspension(); + + $read_watcher = ''; + $timeout_watcher = ''; + $timeout = $optional_timeout->getRemaining(); + if (null !== $timeout) { + $timeout_watcher = EventLoop::delay($timeout, static function () use ($suspension, &$read_watcher, $socket) { + EventLoop::cancel($read_watcher); + + /** @psalm-suppress RedundantCondition - it can be resource|closed-resource */ + if (is_resource($socket)) { + fclose($socket); + } + + $suspension->throw(new Exception\TimeoutException('Connection to socket timed out.')); + }); + } + + $read_watcher = EventLoop::onReadable($socket, static function () use ($suspension, $timeout_watcher) { + EventLoop::cancel($timeout_watcher); + + $suspension->resume(); + }); + + try { + $suspension->suspend(); + } finally { + EventLoop::cancel($read_watcher); + EventLoop::cancel($timeout_watcher); + } + + try { + set_error_handler($error_handler); + $result = stream_socket_enable_crypto($socket, enable: true); + if ($result === false) { + $message = feof($socket) ? 'Connection reset by peer' : 'Unknown error'; + throw new NegotiationException('TLS negotiation failed: ' . $message); + } + } finally { + restore_error_handler(); + } + + if ($result === true) { + break; + } + } + } + } /** @psalm-suppress MissingThrowsDocblock */ return new Network\Internal\Socket($socket); diff --git a/tests/benchmark/Channel/CommunicationBench.php b/tests/benchmark/Channel/CommunicationBench.php index 78dd86d0..02f0264b 100644 --- a/tests/benchmark/Channel/CommunicationBench.php +++ b/tests/benchmark/Channel/CommunicationBench.php @@ -8,7 +8,6 @@ use Psl\Async; use Psl\Channel; use Psl\File; -use Psl\IO; #[Groups(['channel'])] final class CommunicationBench @@ -32,10 +31,9 @@ public function benchBoundedCommunication(): void /** @psalm-suppress MissingThrowsDocblock */ $file = File\open_read_only(__FILE__); - $reader = new IO\Reader($file); /** @psalm-suppress MissingThrowsDocblock */ - while (!$reader->isEndOfFile()) { - $byte = $reader->readByte(); + while (!$file->reachedEndOfDataSource()) { + $byte = $file->readFixedSize(1); /** @psalm-suppress InvalidArgument */ $sender->send($byte); @@ -65,10 +63,9 @@ public function benchUnboundedCommunication(): void /** @psalm-suppress MissingThrowsDocblock */ $file = File\open_read_only(__FILE__); - $reader = new IO\Reader($file); /** @psalm-suppress MissingThrowsDocblock */ - while (!$reader->isEndOfFile()) { - $byte = $reader->readByte(); + while (!$file->reachedEndOfDataSource()) { + $byte = $file->readFixedSize(1); /** @psalm-suppress InvalidArgument */ $sender->send($byte); diff --git a/tests/unit/IO/ReaderTest.php b/tests/unit/IO/ReaderTest.php index 62f08390..09b6709d 100644 --- a/tests/unit/IO/ReaderTest.php +++ b/tests/unit/IO/ReaderTest.php @@ -16,7 +16,7 @@ public function testReadByteOnAnEmptyBufferFillsTheInternalBufferAndMarksTheRead $reader = new IO\Reader($handle); static::assertSame('a', $reader->readByte()); - static::assertTrue($reader->isEndOfFile()); + static::assertTrue($reader->reachedEndOfDataSource()); } public function testReadByteOnAnEmptyBufferThrows(): void @@ -35,7 +35,7 @@ public function testReadEmptyHandle(): void $reader = new IO\Reader($handle); static::assertEmpty($reader->tryRead()); - static::assertTrue($reader->isEndOfFile()); + static::assertTrue($reader->reachedEndOfDataSource()); } public function testReadingFile(): void @@ -66,7 +66,7 @@ public function testReadingFile(): void /** * Handle has reached EOL, but the buffer still contains content. */ - static::assertFalse($reader->isEndOfFile()); + static::assertFalse($reader->reachedEndOfDataSource()); static::assertSame(' ', $reader->readByte()); static::assertSame('ReaderTest', $reader->readFixedSize(10)); } @@ -77,7 +77,7 @@ public function testReadEof(): void $reader = new IO\Reader($handle); static::assertSame('hello', $reader->read()); static::assertSame('', $reader->read()); - static::assertTrue($reader->isEndOfFile()); + static::assertTrue($reader->reachedEndOfDataSource()); static::assertSame('', $reader->read()); } @@ -90,7 +90,7 @@ public function testReadSome(): void static::assertSame('ll', $reader->read(2)); static::assertSame('o,', $reader->read(2)); static::assertSame(' world!', $reader->read(10)); - static::assertTrue($reader->isEndOfFile()); + static::assertTrue($reader->reachedEndOfDataSource()); static::assertSame('', $reader->read()); } @@ -130,7 +130,7 @@ public function testIsEndOfLineWithEofHandle(): void $reader = new IO\Reader($handle); - static::assertTrue($reader->isEndOfFile()); + static::assertTrue($reader->reachedEndOfDataSource()); } public function testIsEndOfLineWithEmptyHandle(): void @@ -138,8 +138,8 @@ public function testIsEndOfLineWithEmptyHandle(): void $handle = new IO\MemoryHandle(); $reader = new IO\Reader($handle); - static::assertTrue($reader->isEndOfFile()); - static::assertTrue($reader->isEndOfFile()); + static::assertTrue($reader->reachedEndOfDataSource()); + static::assertTrue($reader->reachedEndOfDataSource()); } public function testIsEndOfLineWithNonEmptyHandle(): void @@ -147,7 +147,7 @@ public function testIsEndOfLineWithNonEmptyHandle(): void $handle = new IO\MemoryHandle('hello'); $reader = new IO\Reader($handle); - static::assertFalse($reader->isEndOfFile()); + static::assertFalse($reader->reachedEndOfDataSource()); static::assertSame('hello', $reader->readLine()); } } diff --git a/tests/unit/RandomSequence/MersenneTwisterPHPVariantSequenceTest.php b/tests/unit/RandomSequence/MersenneTwisterPHPVariantSequenceTest.php index 39deff8a..c4410a02 100644 --- a/tests/unit/RandomSequence/MersenneTwisterPHPVariantSequenceTest.php +++ b/tests/unit/RandomSequence/MersenneTwisterPHPVariantSequenceTest.php @@ -6,38 +6,25 @@ use PHPUnit\Framework\TestCase; use Psl\RandomSequence\MersenneTwisterPHPVariantSequence; -use Psl\SecureRandom; - -use function mt_rand; -use function mt_srand; -use function time; - -use const MT_RAND_PHP; final class MersenneTwisterPHPVariantSequenceTest extends TestCase { /** * @dataProvider provideSeeds */ - public function testNext(int $seed): void + public function testNext(int $seed, array $expectations): void { - mt_srand($seed, MT_RAND_PHP); $sequence = new MersenneTwisterPHPVariantSequence($seed); - for ($i = 0; $i < 100; $i++) { - static::assertSame(mt_rand(), $sequence->next()); + for ($i = 0; $i < 5; $i++) { + static::assertSame($expectations[$i], $sequence->next()); } } public function provideSeeds(): iterable { - yield [2147483649]; - yield [45635]; - yield [5744]; - yield [456]; - yield [34]; - yield [5]; - yield [time()]; - yield [SecureRandom\int()]; + yield [2147483649, [90281504, 1278257534, 1994752345, 683161987, 992945549]]; + yield [45635, [899019714, 822361780, 1611332592, 632462060, 1431120852]]; + yield [5744, [1962086712, 1462838808, 1331836928, 446021369, 535020186]]; } }