Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
SocketOpener
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
3 / 3
12
100.00% covered (success)
100.00%
1 / 1
 open
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 connect
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 handshake
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3/**
4 * SPDX-License-Identifier: EUPL-1.2
5 *
6 * This file is part of icap-flow.
7 *
8 * Licensed under the EUPL, Version 1.2 only (the "Licence");
9 * you may not use this work except in compliance with the Licence.
10 * You may obtain a copy of the Licence at:
11 *
12 *     https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
13 *
14 * Unless required by applicable law or agreed to in writing, software
15 * distributed under the Licence is distributed on an "AS IS" basis,
16 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 */
18
19declare(strict_types=1);
20
21namespace Ndrstmr\Icap\Transport;
22
23use Amp\ByteStream\StreamException;
24use Amp\Cancellation;
25use Amp\CancelledException;
26use Amp\DeferredCancellation;
27use Amp\Socket;
28use Amp\Socket\ConnectContext;
29use Amp\Socket\Socket as SocketInterface;
30use Ndrstmr\Icap\Config;
31use Ndrstmr\Icap\Exception\IcapConnectionException;
32use Ndrstmr\Icap\Exception\IcapTimeoutException;
33use Ndrstmr\Icap\IcapUri;
34use Revolt\EventLoop;
35
36/**
37 * Opens the connection to an ICAP server in the two phases it has, each with a limit of its own (M10): the TCP
38 * connection within `socketTimeout`, and, with a TLS context, the handshake within `tlsHandshakeTimeout`. The
39 * handshake is a phase of its own because it can hang on a server that accepts the connection and says nothing, and
40 * `Socket\connectTls()` has no limit for it but the cancellation of the whole call.
41 *
42 * Otherwise it does what `connectTls()` does: the peer name of the TLS context is the host when it has none. The
43 * cancellation it is given ends both phases and stays an `Amp\CancelledException`; only the limit of the handshake is
44 * an {@see IcapTimeoutException}. A connection that cannot be made, a failed handshake or a stream that breaks while
45 * it is set up is an {@see IcapConnectionException} with the amphp exception as its cause. One place for the three
46 * ways to open a connection (transport, pool, null pool).
47 *
48 * @internal
49 */
50final class SocketOpener
51{
52    /**
53     * @throws IcapConnectionException When the TCP connection cannot be made or the handshake fails
54     * @throws IcapTimeoutException    When the handshake does not finish within `tlsHandshakeTimeout`
55     * @throws CancelledException      When the cancellation ends the connection setup
56     */
57    public static function open(Config $config, ?Cancellation $cancellation): SocketInterface
58    {
59        try {
60            return self::connect($config, $cancellation);
61        } catch (StreamException $e) {
62            throw new IcapConnectionException(sprintf('Connecting to %s:%d failed.', $config->host, $config->port), 0, $e);
63        }
64    }
65
66    private static function connect(Config $config, ?Cancellation $cancellation): SocketInterface
67    {
68        $tls = $config->getTlsContext();
69        $url = IcapUri::tcp($config->host, $config->port);
70        $context = (new ConnectContext())->withConnectTimeout($config->getSocketTimeout());
71
72        if ($tls === null) {
73            return Socket\connect($url, $context, $cancellation);
74        }
75
76        if ($tls->getPeerName() === '') {
77            $host = parse_url($url, PHP_URL_HOST);
78            $tls = $tls->withPeerName(is_string($host) ? $host : '');
79        }
80
81        $socket = Socket\connect($url, $context->withTlsContext($tls), $cancellation);
82        self::handshake($socket, $config->getTlsHandshakeTimeout(), $cancellation);
83
84        return $socket;
85    }
86
87    /**
88     * The window of the handshake: its own limit, and the cancellation of the connection setup. A socket whose
89     * handshake failed is closed.
90     */
91    private static function handshake(SocketInterface $socket, float $seconds, ?Cancellation $cancellation): void
92    {
93        $window = new DeferredCancellation();
94        $expired = false;
95        $timer = EventLoop::delay($seconds, static function () use (&$expired, $window): void {
96            $expired = true;
97            $window->cancel();
98        });
99        $watch = $cancellation?->subscribe(static function () use ($window): void {
100            $window->cancel();
101        });
102
103        try {
104            $socket->setupTls($window->getCancellation());
105        } catch (\Throwable $e) {
106            $socket->close();
107
108            if ($e instanceof CancelledException) {
109                // The caller's cancellation, or the budget, stays what it is; only the limit of the handshake is ours.
110                $cancellation?->throwIfRequested();
111                if ($expired) {
112                    throw new IcapTimeoutException(
113                        sprintf('The TLS handshake did not finish within %s seconds.', $seconds),
114                        0,
115                        $e,
116                    );
117                }
118            }
119
120            throw $e;
121        } finally {
122            EventLoop::cancel($timer);
123            if ($cancellation !== null && $watch !== null) {
124                $cancellation->unsubscribe($watch);
125            }
126        }
127    }
128}