Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
IcapUri
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
8 / 8
17
100.00% covered (success)
100.00%
1 / 1
 forService
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 assertService
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 serviceOf
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 authority
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parts
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 tcp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 host
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 isHost
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
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;
22
23/**
24 * Builds and checks the URIs the client sends and connects to
25 * (RFC 3507 §4.2 with the RFC 3986 syntax). Host, port, service path and
26 * query stay separate components: a service can neither move the
27 * authority nor carry a fragment, and an IPv6 literal gets its brackets.
28 *
29 * @internal
30 */
31final class IcapUri
32{
33    /** RFC 3986 §3.3 pchar: unreserved, pct-encoded, sub-delims, ":" and "@". */
34    private const string PCHAR = '(?:[A-Za-z0-9\-._~!$&\'()*+,;=:@]|%[0-9A-Fa-f]{2})';
35
36    /** RFC 3986 §3.4 query with its leading "?", optional. */
37    private const string QUERY = '(?:\?(?:' . self::PCHAR . '|[/?])*)?';
38
39    /** An absolute path with an optional query; no fragment. */
40    private const string SERVICE = '#^(?:/' . self::PCHAR . '*)+' . self::QUERY . '\z#';
41
42    /** RFC 3986 §3.2.2 host: an IP literal in brackets or a reg-name, which covers IPv4 addresses. */
43    private const string HOST = '(?:\[[0-9A-Fa-f:.]+\]|(?:[A-Za-z0-9\-._~!$&\'()*+,;=]|%[0-9A-Fa-f]{2})+)';
44
45    /** icap[s]://host[:port], optional path and query; no user info, no fragment. */
46    private const string ICAP = '#^icaps?://(?<authority>(?<host>' . self::HOST . ')(?::[0-9]+)?)'
47        . '(?<service>(?:/' . self::PCHAR . '*)*' . self::QUERY . ')\z#i';
48
49    /**
50     * The ICAP URI of a service on the given server. The port is left out
51     * when it is the ICAP default 1344.
52     *
53     * @throws \InvalidArgumentException When the service is not an absolute
54     *         path with an optional query, the host is not a host name or an
55     *         IP address, or host and port do not form a valid authority
56     */
57    public static function forService(string $host, int $port, string $service): string
58    {
59        self::assertService($service);
60
61        $uri = 'icap://' . self::host($host) . ($port === 1344 ? '' : ':' . $port) . $service;
62        self::authority($uri);
63
64        return $uri;
65    }
66
67    /**
68     * A service on its own: an absolute path with an optional query and no fragment.
69     *
70     * @throws \InvalidArgumentException When it is not
71     */
72    public static function assertService(string $service): void
73    {
74        // Without the leading slash the service would extend the host
75        // ("icap://localhostevil"); "@" or "#" would turn part of it into
76        // user info or a fragment.
77        if (preg_match(self::SERVICE, $service) !== 1) {
78            throw new \InvalidArgumentException(
79                'Service must be an absolute path with an optional query and no fragment: '
80                . var_export($service, true),
81            );
82        }
83    }
84
85    /**
86     * The service a request URI names: its path and query, as they stand in the request line.
87     *
88     * @throws \InvalidArgumentException When the URI does not have the form
89     *         icap[s]://host[:port]/path[?query]
90     */
91    public static function serviceOf(string $uri): string
92    {
93        return self::parts($uri)['service'];
94    }
95
96    /**
97     * Check an ICAP request URI and return its authority, which is the
98     * value of the Host header.
99     *
100     * @throws \InvalidArgumentException When the URI does not have the form
101     *         icap[s]://host[:port]/path[?query]
102     */
103    public static function authority(string $uri): string
104    {
105        return self::parts($uri)['authority'];
106    }
107
108    /**
109     * @return array{authority: string, service: string}
110     *
111     * @throws \InvalidArgumentException When the URI does not have the form icap[s]://host[:port]/path[?query]
112     */
113    private static function parts(string $uri): array
114    {
115        if (preg_match(self::ICAP, $uri, $parts) !== 1 || !self::isHost($parts['host'])) {
116            throw new \InvalidArgumentException(
117                'ICAP request URI must have the form icap://host[:port]/path[?query], without user info or fragment.',
118            );
119        }
120
121        return ['authority' => $parts['authority'], 'service' => $parts['service']];
122    }
123
124    /**
125     * The socket address of a server, tcp://host:port. The host passes the
126     * same check as in {@see forService()}.
127     *
128     * @throws \InvalidArgumentException When the host is not a host name or
129     *         an IP address
130     */
131    public static function tcp(string $host, int $port): string
132    {
133        return sprintf('tcp://%s:%d', self::host($host), $port);
134    }
135
136    /**
137     * The configured host as it goes into a URI. It is checked on its own,
138     * before anything is appended: "/", "?" or "#" in it would otherwise
139     * turn the service into another path, a query or a fragment. An IPv6
140     * literal gets its brackets, which a following port needs
141     * (RFC 3986 §3.2.2).
142     *
143     * @throws \InvalidArgumentException When the host is not a host name or
144     *         an IP address
145     */
146    private static function host(string $host): string
147    {
148        $uriHost = str_contains($host, ':') && !str_starts_with($host, '[') ? '[' . $host . ']' : $host;
149        if (!self::isHost($uriHost)) {
150            throw new \InvalidArgumentException(
151                'ICAP server host must be a host name or an IP address: ' . var_export($host, true),
152            );
153        }
154
155        return $uriHost;
156    }
157
158    /**
159     * Whether the value is a host per RFC 3986 §3.2.2; a bracketed literal
160     * must hold a valid IPv6 address.
161     */
162    private static function isHost(string $host): bool
163    {
164        return preg_match('#^' . self::HOST . '\z#', $host) === 1
165            && (!str_starts_with($host, '[')
166                || filter_var(substr($host, 1, -1), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false);
167    }
168}