Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.57% covered (success)
98.57%
69 / 70
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
HeaderFields
98.57% covered (success)
98.57%
69 / 70
88.89% covered (warning)
88.89%
8 / 9
34
0.00% covered (danger)
0.00%
0 / 1
 parse
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
10
 first
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 single
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 hasToken
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 validated
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
 without
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 isToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 values
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
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
23use Ndrstmr\Icap\Exception\IcapMalformedResponseException;
24
25/**
26 * Reads ICAP header fields by name, ignoring case (RFC 3507 §4.3 via
27 * RFC 7230 §3.2), and checks the fields the client sends.
28 *
29 * Header maps keep every name as the server spelled it, so one field can
30 * sit under several keys ("X-Virus-Name", "x-virus-name"). Verdict,
31 * preview, layout and capability reads go through this class, for parsed
32 * and for cached responses alike, so the spelling never changes a verdict,
33 * a preview size or a body layout.
34 *
35 * On the request side, caller fields pass {@see validated()} before they
36 * are serialized, and {@see without()} keeps fields the library computes
37 * itself out of the caller's reach in any spelling.
38 *
39 * @internal
40 */
41final class HeaderFields
42{
43    /**
44     * Parse header lines into a {@code name => list<value>} map. Honours
45     * RFC 7230 §3.2.4 obsolete line folding: a line that starts with HTAB
46     * or SP is treated as a continuation of the previous header value
47     * (joined with a single space). c-icap uses this folding form for its
48     * multi-line `X-Violations-Found` header, a vendor extension (RFC 3507
49     * defines no virus header).
50     *
51     * Every line is checked as it arrived, before whitespace is trimmed or a
52     * fold is joined: the name is a token (so no space before the colon and
53     * no field that starts blank) and the value and every continuation pass
54     * {@see isText()}. Trimming first would turn `v\0` or ` : v` into a
55     * valid-looking field.
56     *
57     * @param  list<string>            $lines
58     * @return array<string, string[]>
59     *
60     * @throws IcapMalformedResponseException When a line is not a header field
61     */
62    public static function parse(array $lines): array
63    {
64        /** @var array<string, string[]> $headers */
65        $headers = [];
66        $lastName = null;
67
68        foreach ($lines as $line) {
69            if ($line === '') {
70                continue;
71            }
72
73            // Continuation line — append to the previous header's last
74            // value with a single SP, the canonical "unfolded" form.
75            if ($lastName !== null && ($line[0] === " " || $line[0] === "\t")) {
76                if (!self::isText($line)) {
77                    throw new IcapMalformedResponseException('Malformed header line in the ICAP response: a control character in the value.');
78                }
79                $lastIdx = count($headers[$lastName]) - 1;
80                $headers[$lastName][$lastIdx] .= ' ' . trim($line, " \t");
81                continue;
82            }
83
84            // A line that starts blank, with no field before it, is no field: its name is no token.
85            $colon = strpos($line, ':');
86            if ($colon === false) {
87                // What the server sent stays out of the messages: they would end up in logs and in handlers.
88                throw new IcapMalformedResponseException('Malformed header line in the ICAP response: no colon.');
89            }
90            $name = substr($line, 0, $colon);
91            $value = substr($line, $colon + 1);
92            if (!self::isToken($name)) {
93                throw new IcapMalformedResponseException('Malformed header line in the ICAP response: the name is no token.');
94            }
95            if (!self::isText($value)) {
96                throw new IcapMalformedResponseException('Malformed header line in the ICAP response: a control character in the value.');
97            }
98            $headers[$name][] = trim($value, " \t");
99            $lastName = $name;
100        }
101
102        return $headers;
103    }
104
105    /**
106     * The first value of the first listed field that is present, or null.
107     * Used for the vendor virus headers, so the scan verdict and the
108     * preview verdict apply the same policy.
109     *
110     * @param array<string, string[]> $headers
111     * @param list<string>            $names
112     */
113    public static function first(array $headers, array $names): ?string
114    {
115        foreach ($names as $name) {
116            $values = self::values($headers, $name);
117            if ($values !== []) {
118                return $values[0];
119            }
120        }
121
122        return null;
123    }
124
125    /**
126     * The value of a field that may occur only once. Repeating the same
127     * value is harmless; differing values leave the message ambiguous, so
128     * it is rejected instead of being resolved by position or spelling.
129     *
130     * @param array<string, string[]> $headers
131     *
132     * @throws IcapMalformedResponseException When the field carries differing values
133     */
134    public static function single(array $headers, string $name): ?string
135    {
136        $values = array_values(array_unique(self::values($headers, $name)));
137        if (count($values) > 1) {
138            throw new IcapMalformedResponseException(
139                sprintf('ICAP response carries conflicting %s header fields.', $name),
140            );
141        }
142
143        return $values[0] ?? null;
144    }
145
146    /**
147     * Whether a list field carries the token, whatever the spelling of the
148     * field name and of the token. `Connection: keep-alive, close` carries
149     * `close` (RFC 7230 §3.2.2, §6.1); several fields count as one list.
150     *
151     * @param array<string, string[]> $headers
152     */
153    public static function hasToken(array $headers, string $name, string $token): bool
154    {
155        foreach (self::values($headers, $name) as $value) {
156            foreach (explode(',', $value) as $item) {
157                if (strcasecmp(trim($item, " \t"), $token) === 0) {
158                    return true;
159                }
160            }
161        }
162
163        return false;
164    }
165
166    /**
167     * Check header fields from a caller before they are serialized and
168     * return them as name => list of values. A name must be a token and
169     * every value a string that passes {@see isText()}: CR or LF would
170     * start a new header line, and the wire format cannot escape them, so
171     * such input is refused rather than cleaned up.
172     *
173     * @param  array<mixed>                $headers name => value or list of values
174     * @return array<string, list<string>>
175     *
176     * @throws \InvalidArgumentException When a name or a value cannot be sent
177     */
178    public static function validated(array $headers, string $kind): array
179    {
180        $checked = [];
181        foreach ($headers as $name => $values) {
182            if (!is_string($name) || !self::isToken($name)) {
183                throw new \InvalidArgumentException(sprintf(
184                    '%s header name must be a token (RFC 7230 §3.2.6): %s',
185                    $kind,
186                    var_export($name, true),
187                ));
188            }
189            foreach (is_array($values) ? $values : [$values] as $value) {
190                if (!is_string($value) || !self::isText($value)) {
191                    throw new \InvalidArgumentException(sprintf(
192                        '%s header %s needs string values without control characters; refusing to send.',
193                        $kind,
194                        $name,
195                    ));
196                }
197                $checked[$name][] = $value;
198            }
199        }
200
201        return $checked;
202    }
203
204    /**
205     * The map without the named fields, in any spelling. For fields the
206     * library computes itself: a caller value must neither replace them
207     * nor go out next to them under another spelling.
208     *
209     * @template T
210     *
211     * @param  array<string, T> $headers
212     * @param  list<string>     $names
213     * @return array<string, T>
214     */
215    public static function without(array $headers, array $names): array
216    {
217        $names = array_map(strtolower(...), $names);
218
219        return array_filter(
220            $headers,
221            static fn (string $field): bool => !in_array(strtolower($field), $names, true),
222            ARRAY_FILTER_USE_KEY,
223        );
224    }
225
226    /**
227     * Whether the value is a token (RFC 7230 §3.2.6), the syntax of
228     * header names and of ICAP and HTTP methods.
229     */
230    public static function isToken(string $value): bool
231    {
232        // \z instead of $, which also matches before a trailing newline.
233        return preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+\z/', $value) === 1;
234    }
235
236    /**
237     * Whether the value holds only HTAB, SP, visible ASCII and obs-text,
238     * as header values and reason phrases must (RFC 7230 §3.2, §3.1.2).
239     * Any other control character could end the line early.
240     */
241    public static function isText(string $value): bool
242    {
243        return preg_match('/[\x00-\x08\x0A-\x1F\x7F]/', $value) !== 1;
244    }
245
246    /**
247     * All values of a field across every spelling, in map order.
248     *
249     * @param  array<string, string[]> $headers
250     * @return list<string>
251     */
252    public static function values(array $headers, string $name): array
253    {
254        $values = [];
255        foreach ($headers as $field => $fieldValues) {
256            // A numeric field name becomes an int key in a PHP array.
257            if (strcasecmp((string) $field, $name) === 0) {
258                foreach ($fieldValues as $value) {
259                    $values[] = $value;
260                }
261            }
262        }
263
264        return $values;
265    }
266}