Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.60% |
326 / 334 |
|
97.30% |
36 / 37 |
CRAP | |
0.00% |
0 / 1 |
| IcapClient | |
97.60% |
326 / 334 |
|
97.30% |
36 / 37 |
98 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| forServer | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| create | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
| parserFor | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| request | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| requestWith | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
2 | |||
| run | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
2 | |||
| executeRaw | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| options | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
6 | |||
| scanFile | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| scanFileDiscardingBody | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| scanFileWith | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
1 | |||
| scanFileWithPreview | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| scanFileWithPreviewDiscardingBody | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| scanFileWithPreviewWith | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
4 | |||
| scanFileWithPreviewStrict | |
100.00% |
50 / 50 |
|
100.00% |
1 / 1 |
5 | |||
| previewResult | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
8 | |||
| assertVerifiedService | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| wholeFileHeaders | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| responseReader | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| assertCanDiscard | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| sessionTransport | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| discardingTransport | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| resolvePreviewSize | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| cachedOptions | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
5 | |||
| boundedPreviewSize | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| readPreview | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
6 | |||
| buildServiceUri | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| optionsCacheKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| logContext | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| logFailure | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| interpretResponse | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
6 | |||
| assertOptionsStatus | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| assertSuccessfulStatus | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
| extractVirusName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| validateIcapHeaders | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| mergeHeaders | |
100.00% |
6 / 6 |
|
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 | |
| 19 | declare(strict_types=1); |
| 20 | |
| 21 | namespace Ndrstmr\Icap; |
| 22 | |
| 23 | use Amp\Cancellation; |
| 24 | use Amp\Future; |
| 25 | use Ndrstmr\Icap\Cache\OptionsCacheInterface; |
| 26 | use Ndrstmr\Icap\Cache\OptionsCacheKey; |
| 27 | use Ndrstmr\Icap\Cache\OptionsEntry; |
| 28 | use Ndrstmr\Icap\DTO\HttpResponse; |
| 29 | use Ndrstmr\Icap\DTO\IcapRequest; |
| 30 | use Ndrstmr\Icap\DTO\IcapResponse; |
| 31 | use Ndrstmr\Icap\DTO\ScanResult; |
| 32 | use Ndrstmr\Icap\Exception\IcapClientException; |
| 33 | use Ndrstmr\Icap\Exception\IcapProtocolException; |
| 34 | use Ndrstmr\Icap\Exception\IcapServerException; |
| 35 | use Ndrstmr\Icap\Exception\IcapStorageException; |
| 36 | use Ndrstmr\Icap\Transport\AsyncAmpTransport; |
| 37 | use Ndrstmr\Icap\Transport\DiscardingTransport; |
| 38 | use Ndrstmr\Icap\Transport\DiscardingTransportSession; |
| 39 | use Ndrstmr\Icap\Transport\SessionAwareTransport; |
| 40 | use Ndrstmr\Icap\Transport\SynchronousStreamTransport; |
| 41 | use Ndrstmr\Icap\Transport\TransportInterface; |
| 42 | use Ndrstmr\Icap\Transport\TransportSession; |
| 43 | use Psr\Log\LoggerInterface; |
| 44 | use Psr\Log\NullLogger; |
| 45 | |
| 46 | /** |
| 47 | * Core asynchronous ICAP client used by the synchronous wrapper. |
| 48 | * |
| 49 | * With a {@see CicapVerdict} the client runs in the verified mode for the c-icap `virus_scan` service with ClamAV: every |
| 50 | * answer to a scan, to a preview and to the rest of the file, whichever helper asked for it, is read by that verdict, and |
| 51 | * the client scans with the service of the verdict only. Without one, the default rules apply (a 204, and a 200 or 206 |
| 52 | * without a header of the {@see Config} list, are clean). |
| 53 | */ |
| 54 | final class IcapClient implements IcapClientInterface, DiscardingIcapClient |
| 55 | { |
| 56 | private PreviewStrategyInterface $previewStrategy; |
| 57 | private LoggerInterface $logger; |
| 58 | |
| 59 | /** |
| 60 | * @param CicapVerdict|null $verdict Turns the verified mode on, for the service the verdict is made for. It decides |
| 61 | * every answer, so a preview strategy of your own, which could decide before it, |
| 62 | * is refused. |
| 63 | * |
| 64 | * @throws \InvalidArgumentException When a verdict is combined with a preview strategy other than the default one |
| 65 | */ |
| 66 | public function __construct( |
| 67 | private Config $config, |
| 68 | private TransportInterface $transport, |
| 69 | private RequestFormatterInterface $formatter, |
| 70 | private ResponseParserInterface $parser, |
| 71 | ?PreviewStrategyInterface $previewStrategy = null, |
| 72 | ?LoggerInterface $logger = null, |
| 73 | private ?OptionsCacheInterface $optionsCache = null, |
| 74 | private readonly ?CicapVerdict $verdict = null, |
| 75 | ) { |
| 76 | if ($verdict !== null && $previewStrategy !== null && !$previewStrategy instanceof DefaultPreviewStrategy) { |
| 77 | throw new \InvalidArgumentException( |
| 78 | $previewStrategy::class . ' would decide on the answer to a preview before the verified mode does; use one of the two.', |
| 79 | ); |
| 80 | } |
| 81 | $this->previewStrategy = $previewStrategy ?? new DefaultPreviewStrategy( |
| 82 | $config->getVirusFoundHeaders(), |
| 83 | ); |
| 84 | $this->logger = $logger ?? new NullLogger(); |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Convenience factory for synchronous environments. |
| 89 | */ |
| 90 | public static function forServer(string $host, int $port = 1344, ?CicapVerdict $verdict = null): self |
| 91 | { |
| 92 | $config = new Config($host, $port); |
| 93 | return new self( |
| 94 | $config, |
| 95 | new SynchronousStreamTransport(), |
| 96 | new RequestFormatter(), |
| 97 | self::parserFor($config), |
| 98 | verdict: $verdict, |
| 99 | ); |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * Factory using the default async transport. |
| 104 | */ |
| 105 | public static function create(): self |
| 106 | { |
| 107 | $config = new Config('127.0.0.1'); |
| 108 | return new self( |
| 109 | $config, |
| 110 | new AsyncAmpTransport(), |
| 111 | new RequestFormatter(), |
| 112 | self::parserFor($config), |
| 113 | new DefaultPreviewStrategy(), |
| 114 | ); |
| 115 | } |
| 116 | |
| 117 | private static function parserFor(Config $config): ResponseParser |
| 118 | { |
| 119 | return new ResponseParser( |
| 120 | maxHeaderCount: $config->getMaxHeaderCount(), |
| 121 | maxHeaderLineLength: $config->getMaxHeaderLineLength(), |
| 122 | ); |
| 123 | } |
| 124 | |
| 125 | /** |
| 126 | * @throws \InvalidArgumentException In the verified mode, when the request is for another service than the one of the |
| 127 | * verdict |
| 128 | */ |
| 129 | #[\Override] |
| 130 | public function request(IcapRequest $request, ?Cancellation $cancellation = null): Future |
| 131 | { |
| 132 | if ($this->verdict !== null) { |
| 133 | $this->assertVerifiedService(IcapUri::serviceOf($request->uri)); |
| 134 | } |
| 135 | |
| 136 | return $this->requestWith($request, $cancellation, null); |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * One request and the verdict on its response; with a budget, the body of the response is received and dropped. |
| 141 | * |
| 142 | * @return Future<ScanResult> |
| 143 | */ |
| 144 | private function requestWith(IcapRequest $request, ?Cancellation $cancellation, ?int $discardBudget): Future |
| 145 | { |
| 146 | return $this->run( |
| 147 | OperationDeadline::begin($cancellation, $this->config->getOperationTimeout()), |
| 148 | function (OperationDeadline $deadline) use ($request, $discardBudget): ScanResult { |
| 149 | $context = $this->logContext($request); |
| 150 | $this->logger->info('ICAP request started', $context); |
| 151 | |
| 152 | $response = null; |
| 153 | try { |
| 154 | $response = $this->executeRaw($request, $deadline, $discardBudget)->await(); |
| 155 | // An answer that comes in after the end of the operation is none (M10). |
| 156 | $deadline->checkpoint(); |
| 157 | // The service had the whole message unless the request ended in the start of it. |
| 158 | $result = $this->interpretResponse($response, $this->config, complete: !$request->isPartialPreview()); |
| 159 | } catch (\Throwable $e) { |
| 160 | $e = $deadline->outcome($e); |
| 161 | $this->logFailure($context, $response, $e); |
| 162 | throw $e; |
| 163 | } |
| 164 | |
| 165 | $this->logger->info('ICAP request completed', $context + [ |
| 166 | 'statusCode' => $response->statusCode, |
| 167 | 'infected' => $result->isInfected(), |
| 168 | ]); |
| 169 | |
| 170 | return $result; |
| 171 | }, |
| 172 | ); |
| 173 | } |
| 174 | |
| 175 | /** |
| 176 | * Runs one public operation in a fiber of its own, inside the budget that began with its call (M10). What leaves |
| 177 | * the operation is checked against the clock before it is returned: a result that came in late (a producer, a |
| 178 | * transport or a cache that held the event loop) is a timeout, not a success. The deadline is closed in `finally`. |
| 179 | * |
| 180 | * @template T |
| 181 | * |
| 182 | * @param \Closure(OperationDeadline): T $operation |
| 183 | * |
| 184 | * @return Future<T> |
| 185 | */ |
| 186 | private function run(OperationDeadline $deadline, \Closure $operation): Future |
| 187 | { |
| 188 | /** @var Future<T> $future */ |
| 189 | $future = \Amp\async(static function () use ($deadline, $operation): mixed { |
| 190 | try { |
| 191 | $result = $operation($deadline); |
| 192 | $deadline->checkpoint(); |
| 193 | |
| 194 | return $result; |
| 195 | } catch (\Throwable $e) { |
| 196 | throw $deadline->outcome($e); |
| 197 | } finally { |
| 198 | $deadline->close(); |
| 199 | } |
| 200 | }); |
| 201 | |
| 202 | return $future; |
| 203 | } |
| 204 | |
| 205 | /** |
| 206 | * Send the ICAP request and return the parsed {@see IcapResponse} |
| 207 | * without the fail-secure status-code interpretation pass. Used |
| 208 | * internally by the preview flow, where `100 Continue` is a |
| 209 | * legitimate intermediate response. |
| 210 | * |
| 211 | * Visibility is intentionally `protected`: this method bypasses the |
| 212 | * fail-secure status-code interpretation in {@see interpretResponse()}, |
| 213 | * so exposing it as part of the public surface would let callers |
| 214 | * silently turn unexpected statuses (e.g. a stray `100` outside the |
| 215 | * preview flow) into a `clean` verdict. External callers must use |
| 216 | * {@see request()} or one of the `scanFile*()` methods instead. The class |
| 217 | * is final: there is no outside access to this method. |
| 218 | * |
| 219 | * With a `$discardBudget` the response is received and checked by the transport without being kept |
| 220 | * (see {@see DiscardingIcapClient}), and the parser is not asked. |
| 221 | * |
| 222 | * @return Future<IcapResponse> |
| 223 | */ |
| 224 | protected function executeRaw(IcapRequest $request, ?Cancellation $cancellation = null, ?int $discardBudget = null): Future |
| 225 | { |
| 226 | /** @var Future<IcapResponse> $future */ |
| 227 | $future = \Amp\async(function () use ($request, $cancellation, $discardBudget): IcapResponse { |
| 228 | $chunks = $this->formatter->format($request); |
| 229 | if ($discardBudget !== null) { |
| 230 | return $this->discardingTransport()->requestDiscardingBody($this->config, $chunks, $discardBudget, $cancellation)->await(); |
| 231 | } |
| 232 | $responseString = $this->transport->request($this->config, $chunks, $cancellation)->await(); |
| 233 | return $this->parser->parse($responseString); |
| 234 | }); |
| 235 | |
| 236 | return $future; |
| 237 | } |
| 238 | |
| 239 | #[\Override] |
| 240 | public function options(string $service, ?Cancellation $cancellation = null): Future |
| 241 | { |
| 242 | $deadline = OperationDeadline::begin($cancellation, $this->config->getOperationTimeout()); |
| 243 | |
| 244 | // Validate first so injection attempts never reach the cache key. |
| 245 | $uri = $this->buildServiceUri($service); |
| 246 | |
| 247 | $cacheKey = $this->optionsCacheKey($service); |
| 248 | |
| 249 | return $this->run($deadline, function (OperationDeadline $deadline) use ($uri, $cacheKey): IcapResponse { |
| 250 | $cached = $this->cachedOptions($cacheKey, $deadline); |
| 251 | if ($cached !== null) { |
| 252 | return $cached; |
| 253 | } |
| 254 | |
| 255 | $request = new IcapRequest('OPTIONS', $uri); |
| 256 | $context = $this->logContext($request); |
| 257 | $this->logger->info('ICAP request started', $context); |
| 258 | |
| 259 | $response = null; |
| 260 | try { |
| 261 | $response = $this->executeRaw($request, $deadline)->await(); |
| 262 | $received = hrtime(true); |
| 263 | $deadline->checkpoint(); |
| 264 | $this->assertOptionsStatus($response->statusCode); |
| 265 | } catch (\Throwable $e) { |
| 266 | $e = $deadline->outcome($e); |
| 267 | $this->logFailure($context, $response, $e); |
| 268 | throw $e; |
| 269 | } |
| 270 | |
| 271 | $this->logger->info('ICAP request completed', $context + [ |
| 272 | 'statusCode' => $response->statusCode, |
| 273 | ]); |
| 274 | |
| 275 | // Cache the answer under the key of its endpoint, service and security profile for the server's |
| 276 | // Options-TTL (RFC 3507 §4.10.2) at most an hour. The time counts from the moment the answer came in: |
| 277 | // foreign code (the logger) may have held it since, and an answer whose time is over is not written. |
| 278 | if ($this->optionsCache !== null) { |
| 279 | // The logger is foreign code: a call that was cancelled, or ran out of time, while it logged the |
| 280 | // answer does not begin a write to the cache. |
| 281 | $deadline->checkpoint(); |
| 282 | $istag = HeaderFields::single($response->headers, 'ISTag'); |
| 283 | $ttl = OptionsEntry::ttl(HeaderFields::single($response->headers, 'Options-TTL')) - intdiv(hrtime(true) - $received, 1_000_000_000); |
| 284 | if ($ttl > 0 && OptionsEntry::keeps($response)) { |
| 285 | $this->optionsCache->set($cacheKey, $response, $ttl, $istag); |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | return $response; |
| 290 | }); |
| 291 | } |
| 292 | |
| 293 | /** |
| 294 | * Scan a local file via RESPMOD by wrapping it in a synthesized HTTP |
| 295 | * response envelope (Content-Type: application/octet-stream, |
| 296 | * Content-Length: <size of the open file>). The file contents are |
| 297 | * streamed into the encapsulated body, so the full file never lives in |
| 298 | * memory. |
| 299 | * |
| 300 | * The file is opened once, inside the returned Future, after a |
| 301 | * cancellation that was already requested has been honoured, read |
| 302 | * through amphp/file (see {@see LocalFile}) and closed when the |
| 303 | * operation ends. Its size is that of the open file, and the body goes |
| 304 | * out with exactly that many bytes: a file that grew or shrank since is |
| 305 | * an error before the final chunk. The caller's own streams (see |
| 306 | * {@see request()}) are not touched. |
| 307 | * |
| 308 | * The Future fails with an {@see IcapStorageException} when the file |
| 309 | * cannot be opened, sized, read to its end or closed, does not have the |
| 310 | * size it had when it was opened, or the request did not read it to its |
| 311 | * end (a formatter or transport of your own that took less). |
| 312 | */ |
| 313 | #[\Override] |
| 314 | public function scanFile( |
| 315 | string $service, |
| 316 | string $filePath, |
| 317 | array $extraHeaders = [], |
| 318 | ?Cancellation $cancellation = null, |
| 319 | ): Future { |
| 320 | return $this->scanFileWith($service, $filePath, $extraHeaders, $cancellation, null); |
| 321 | } |
| 322 | |
| 323 | /** |
| 324 | * {@see scanFile()} with the body of the response received and dropped, and only its size kept |
| 325 | * ({@see IcapResponse::$discardedBodyBytes}); for a server that echoes a large file back. See |
| 326 | * {@see DiscardingIcapClient}. |
| 327 | * |
| 328 | * @param array<string, string|string[]> $extraHeaders |
| 329 | * |
| 330 | * @return Future<ScanResult> |
| 331 | * |
| 332 | * @throws \InvalidArgumentException When the budget is below 1 |
| 333 | * @throws \LogicException When the transport or the parser cannot discard |
| 334 | */ |
| 335 | #[\Override] |
| 336 | public function scanFileDiscardingBody( |
| 337 | string $service, |
| 338 | string $filePath, |
| 339 | int $maxResponseBytes, |
| 340 | array $extraHeaders = [], |
| 341 | ?Cancellation $cancellation = null, |
| 342 | ): Future { |
| 343 | $this->assertCanDiscard($maxResponseBytes); |
| 344 | |
| 345 | return $this->scanFileWith($service, $filePath, $extraHeaders, $cancellation, $maxResponseBytes); |
| 346 | } |
| 347 | |
| 348 | /** |
| 349 | * @param array<string, string|string[]> $extraHeaders |
| 350 | * |
| 351 | * @return Future<ScanResult> |
| 352 | */ |
| 353 | private function scanFileWith( |
| 354 | string $service, |
| 355 | string $filePath, |
| 356 | array $extraHeaders, |
| 357 | ?Cancellation $cancellation, |
| 358 | ?int $discardBudget, |
| 359 | ): Future { |
| 360 | $this->assertVerifiedService($service); |
| 361 | $deadline = OperationDeadline::begin($cancellation, $this->config->getOperationTimeout()); |
| 362 | |
| 363 | // Validate first so injection attempts never reach the socket. |
| 364 | $this->validateIcapHeaders($extraHeaders); |
| 365 | $uri = $this->buildServiceUri($service); |
| 366 | |
| 367 | return $this->run($deadline, function (OperationDeadline $deadline) use ($uri, $filePath, $extraHeaders, $discardBudget): ScanResult { |
| 368 | // A cancellation that was already requested, or an operation that is over, opens no file. |
| 369 | $deadline->checkpoint(); |
| 370 | |
| 371 | $file = LocalFile::open($filePath, $deadline); |
| 372 | try { |
| 373 | $httpResponse = new HttpResponse( |
| 374 | statusCode: 200, |
| 375 | headers: [ |
| 376 | 'Content-Type' => ['application/octet-stream'], |
| 377 | 'Content-Length' => [(string) $file->size], |
| 378 | ], |
| 379 | body: $file, |
| 380 | ); |
| 381 | |
| 382 | $request = new IcapRequest( |
| 383 | method: 'RESPMOD', |
| 384 | uri: $uri, |
| 385 | // The full request goes out without a preview handshake, so a |
| 386 | // caller Preview would announce one that never happens; it stays |
| 387 | // out in any spelling. Allow, e.g. 204, is valid here. |
| 388 | headers: $this->wholeFileHeaders($extraHeaders), |
| 389 | encapsulatedResponse: $httpResponse, |
| 390 | ); |
| 391 | |
| 392 | $result = $this->requestWith($request, $deadline, $discardBudget)->await(); |
| 393 | // A formatter or transport that took less than the file leaves the verdict to another body. |
| 394 | $file->assertRead(); |
| 395 | $file->close(); |
| 396 | |
| 397 | return $result; |
| 398 | } finally { |
| 399 | $file->discard(); |
| 400 | } |
| 401 | }); |
| 402 | } |
| 403 | |
| 404 | /** |
| 405 | * Scan a local file via RESPMOD using a preview. The first |
| 406 | * {@see $previewSize} bytes are sent along with the Preview / |
| 407 | * Allow: 204 headers; if the file fits entirely within the preview |
| 408 | * the request is terminated with `0; ieof\r\n\r\n` (RFC 3507 §4.5) |
| 409 | * and no continuation round-trip is necessary. If the server answers |
| 410 | * `100 Continue`, the rest of the file follows on the same connection as |
| 411 | * part of the same request, which takes a transport with sessions |
| 412 | * ({@see SessionAwareTransport}). |
| 413 | * |
| 414 | * The preview size is 0 to 1 MiB (1048576 bytes); `0` sends the |
| 415 | * headers alone. A size outside that range is refused with an |
| 416 | * \InvalidArgumentException before the file or a socket is touched. |
| 417 | * `ieof` is sent only after the end of the file was observed and its |
| 418 | * length matches the size of the open file, and the rest of |
| 419 | * the file goes out with exactly that length as well. Like |
| 420 | * {@see scanFile()}, the file is opened once inside the returned Future |
| 421 | * and closed when the operation ends. |
| 422 | * |
| 423 | * The Future fails with an {@see IcapStorageException} when the file |
| 424 | * cannot be opened, sized, read or closed, or has another length than it |
| 425 | * had when it was opened. |
| 426 | * |
| 427 | * @throws \InvalidArgumentException When $previewSize is outside 0..1 MiB |
| 428 | * @throws \LogicException When the transport has no sessions, so that it cannot continue a preview |
| 429 | * on the connection it began on |
| 430 | */ |
| 431 | #[\Override] |
| 432 | public function scanFileWithPreview( |
| 433 | string $service, |
| 434 | string $filePath, |
| 435 | ?int $previewSize = null, |
| 436 | array $extraHeaders = [], |
| 437 | ?Cancellation $cancellation = null, |
| 438 | ): Future { |
| 439 | return $this->scanFileWithPreviewWith($service, $filePath, $previewSize, $extraHeaders, $cancellation, null); |
| 440 | } |
| 441 | |
| 442 | /** |
| 443 | * {@see scanFileWithPreview()} with the body of every response of the exchange received and dropped, as |
| 444 | * {@see scanFileDiscardingBody()} does. The budget holds for each response on its own. |
| 445 | * |
| 446 | * @param array<string, string|string[]> $extraHeaders |
| 447 | * |
| 448 | * @return Future<ScanResult> |
| 449 | * |
| 450 | * @throws \InvalidArgumentException When the budget is below 1, or $previewSize is outside 0..1 MiB |
| 451 | * @throws \LogicException When the transport or the parser cannot discard, the transport has no |
| 452 | * sessions, or the client has a preview strategy other than |
| 453 | * {@see DefaultPreviewStrategy} |
| 454 | */ |
| 455 | #[\Override] |
| 456 | public function scanFileWithPreviewDiscardingBody( |
| 457 | string $service, |
| 458 | string $filePath, |
| 459 | int $maxResponseBytes, |
| 460 | ?int $previewSize = null, |
| 461 | array $extraHeaders = [], |
| 462 | ?Cancellation $cancellation = null, |
| 463 | ): Future { |
| 464 | $this->assertCanDiscard($maxResponseBytes); |
| 465 | // A strategy of its own may read the body of the answer to the preview, which is not there. |
| 466 | if (!$this->previewStrategy instanceof DefaultPreviewStrategy) { |
| 467 | throw new \LogicException($this->previewStrategy::class . ' may judge the body of the answer to the preview, which this mode does not keep; only ' . DefaultPreviewStrategy::class . ' is used with it.'); |
| 468 | } |
| 469 | |
| 470 | return $this->scanFileWithPreviewWith($service, $filePath, $previewSize, $extraHeaders, $cancellation, $maxResponseBytes); |
| 471 | } |
| 472 | |
| 473 | /** |
| 474 | * @param array<string, string|string[]> $extraHeaders |
| 475 | * |
| 476 | * @return Future<ScanResult> |
| 477 | */ |
| 478 | private function scanFileWithPreviewWith( |
| 479 | string $service, |
| 480 | string $filePath, |
| 481 | ?int $previewSize, |
| 482 | array $extraHeaders, |
| 483 | ?Cancellation $cancellation, |
| 484 | ?int $discardBudget, |
| 485 | ): Future { |
| 486 | $this->assertVerifiedService($service); |
| 487 | $transport = $this->sessionTransport(); |
| 488 | $deadline = OperationDeadline::begin($cancellation, $this->config->getOperationTimeout()); |
| 489 | |
| 490 | if ($previewSize !== null && ($previewSize < 0 || $previewSize > self::MAX_PREVIEW_SIZE)) { |
| 491 | throw new \InvalidArgumentException(sprintf( |
| 492 | 'Preview size must be between 0 and %d bytes, got: %d', |
| 493 | self::MAX_PREVIEW_SIZE, |
| 494 | $previewSize, |
| 495 | )); |
| 496 | } |
| 497 | $this->validateIcapHeaders($extraHeaders); |
| 498 | |
| 499 | return $this->run($deadline, function (OperationDeadline $deadline) use ($transport, $service, $filePath, $previewSize, $extraHeaders, $discardBudget): ScanResult { |
| 500 | $previewSize = $this->resolvePreviewSize($service, $previewSize, $deadline); |
| 501 | // A cancellation that was already requested, or an operation that is over, opens no file. |
| 502 | $deadline->checkpoint(); |
| 503 | |
| 504 | $file = LocalFile::open($filePath, $deadline); |
| 505 | |
| 506 | try { |
| 507 | $result = $this->scanFileWithPreviewStrict($transport, $service, $file, $previewSize, $extraHeaders, $deadline, $discardBudget); |
| 508 | $file->close(); |
| 509 | |
| 510 | return $result; |
| 511 | } finally { |
| 512 | $file->discard(); |
| 513 | } |
| 514 | }); |
| 515 | } |
| 516 | |
| 517 | /** |
| 518 | * RFC 3507 §4.5 preview-continue: the preview and the continuation |
| 519 | * travel on the same socket as one logical ICAP request. |
| 520 | * |
| 521 | * @param array<string, string|string[]> $extraHeaders |
| 522 | */ |
| 523 | private function scanFileWithPreviewStrict( |
| 524 | SessionAwareTransport $transport, |
| 525 | string $service, |
| 526 | LocalFile $file, |
| 527 | int $previewSize, |
| 528 | array $extraHeaders, |
| 529 | OperationDeadline $deadline, |
| 530 | ?int $discardBudget, |
| 531 | ): ScanResult { |
| 532 | $fileSize = $file->size; |
| 533 | [$previewBytes, $previewIsComplete] = $this->readPreview($file, $previewSize); |
| 534 | |
| 535 | $previewEnvelope = new HttpResponse( |
| 536 | statusCode: 200, |
| 537 | headers: [ |
| 538 | 'Content-Type' => ['application/octet-stream'], |
| 539 | 'Content-Length' => [(string) $fileSize], |
| 540 | ], |
| 541 | body: $previewBytes, |
| 542 | ); |
| 543 | $headers = $this->mergeHeaders($extraHeaders, [ |
| 544 | 'Preview' => [(string) $previewSize], |
| 545 | 'Allow' => ['204'], |
| 546 | ]); |
| 547 | $previewRequest = new IcapRequest( |
| 548 | method: 'RESPMOD', |
| 549 | uri: $this->buildServiceUri($service), |
| 550 | headers: $headers, |
| 551 | encapsulatedResponse: $previewEnvelope, |
| 552 | previewIsComplete: $previewIsComplete, |
| 553 | ); |
| 554 | |
| 555 | $session = $transport->openSession($this->config, $deadline); |
| 556 | // The socket is closed unless the exchange ended in a verdict that |
| 557 | // passed every check below and no response asked for a close. An |
| 558 | // exception, a status the flow does not allow or a 100 after `ieof` |
| 559 | // leaves $reusable false, and the socket is never offered to the |
| 560 | // pool, where the next user could see misaligned bytes. |
| 561 | $reusable = false; |
| 562 | try { |
| 563 | // A session that cannot discard is found out before a byte is sent on it. |
| 564 | $readResponse = $this->responseReader($session, $discardBudget); |
| 565 | $session->write($this->formatter->format($previewRequest)); |
| 566 | $previewIcapResponse = $readResponse(); |
| 567 | // Nothing is decided, and nothing pooled, on an answer that came in after the end of the operation. |
| 568 | $deadline->checkpoint(); |
| 569 | $serverCloses = HeaderFields::hasToken($previewIcapResponse->headers, 'Connection', 'close'); |
| 570 | |
| 571 | if ($previewIsComplete) { |
| 572 | $result = $this->interpretResponse($previewIcapResponse, $this->config); |
| 573 | $reusable = !$serverCloses; |
| 574 | return $result; |
| 575 | } |
| 576 | |
| 577 | $result = $this->previewResult($previewIcapResponse, $deadline); |
| 578 | if ($result !== null) { |
| 579 | // Server's intermediate response is the verdict — nothing |
| 580 | // to send on this socket. |
| 581 | $reusable = !$serverCloses; |
| 582 | return $result; |
| 583 | } |
| 584 | |
| 585 | // §4.5 continuation: ONLY the chunked body remainder, no |
| 586 | // new ICAP head. The original RESPMOD envelope still wraps |
| 587 | // the entire scan. Stream from the current position (past the |
| 588 | // preview bytes) in CHUNK_SIZE blocks — never buffer the |
| 589 | // entire remainder in memory (v2.1.2 OOM fix). The rest has the |
| 590 | // length the envelope announced, or the exchange fails (M07). |
| 591 | $session->write((new ChunkedBodyEncoder())->encodeRemainderFromStream( |
| 592 | $file, |
| 593 | $fileSize - strlen($previewBytes), |
| 594 | )); |
| 595 | // A session that took less than the rest leaves the verdict to a body that is not the file. |
| 596 | $file->assertRead(); |
| 597 | |
| 598 | $finalIcapResponse = $readResponse(); |
| 599 | $deadline->checkpoint(); |
| 600 | $result = $this->interpretResponse($finalIcapResponse, $this->config); |
| 601 | $reusable = !$serverCloses |
| 602 | && !HeaderFields::hasToken($finalIcapResponse->headers, 'Connection', 'close'); |
| 603 | return $result; |
| 604 | } finally { |
| 605 | if ($reusable) { |
| 606 | $session->release(); |
| 607 | } else { |
| 608 | $session->close(); |
| 609 | } |
| 610 | } |
| 611 | } |
| 612 | |
| 613 | /** |
| 614 | * What the answer to a preview that holds only the start of the file decides: the verdict if the answer is one, |
| 615 | * null if the service wants the rest. |
| 616 | * |
| 617 | * @throws IcapProtocolException When the verified mode finds that the service did not scan the file |
| 618 | */ |
| 619 | private function previewResult(IcapResponse $answer, OperationDeadline $deadline): ?ScanResult |
| 620 | { |
| 621 | $failure = IcapStatus::failure($answer->statusCode); |
| 622 | if ($failure !== null) { |
| 623 | throw $failure; |
| 624 | } |
| 625 | if (!in_array($answer->statusCode, [100, 200, 204, 206], true)) { |
| 626 | throw new IcapProtocolException('Unexpected preview status code: ' . $answer->statusCode, $answer->statusCode); |
| 627 | } |
| 628 | |
| 629 | if ($this->verdict !== null) { |
| 630 | // Only a 100 asks for the rest, and only one that reports no infection does. A 204 now is a service that does |
| 631 | // not scan this file. Either way the exception ends the exchange: no continuation, no reuse of the socket. |
| 632 | if ($answer->statusCode === 100) { |
| 633 | $this->verdict->assertContinuation($answer); |
| 634 | |
| 635 | return null; |
| 636 | } |
| 637 | |
| 638 | return $this->interpretResponse($answer, $this->config, complete: false); |
| 639 | } |
| 640 | |
| 641 | $decision = $this->previewStrategy->handlePreviewResponse($answer); |
| 642 | // The strategy is foreign code. A verdict that came in after the end of the operation, or after the caller |
| 643 | // cancelled, is none: nothing is continued, and the socket is not given back (M10). |
| 644 | $deadline->checkpoint(); |
| 645 | |
| 646 | if (($answer->statusCode === 100) !== ($decision === PreviewDecision::CONTINUE_SENDING)) { |
| 647 | throw new IcapProtocolException('Preview decision does not match ICAP status: ' . $answer->statusCode, $answer->statusCode); |
| 648 | } |
| 649 | |
| 650 | return $decision === PreviewDecision::CONTINUE_SENDING ? null : new ScanResult( |
| 651 | isInfected: $decision === PreviewDecision::ABORT_INFECTED, |
| 652 | virusName: $decision === PreviewDecision::ABORT_INFECTED |
| 653 | ? $this->extractVirusName($answer, $this->config) |
| 654 | : null, |
| 655 | originalResponse: $answer, |
| 656 | ); |
| 657 | } |
| 658 | |
| 659 | /** |
| 660 | * The verified mode holds for the service of its verdict and for no other: the answers of another one, say a service |
| 661 | * that skips a type, look like those of a scan. Checked at the call, before a file or a connection is touched. |
| 662 | * |
| 663 | * @throws \InvalidArgumentException When the mode is on and the service is not the one of the verdict |
| 664 | */ |
| 665 | private function assertVerifiedService(string $service): void |
| 666 | { |
| 667 | if ($this->verdict !== null && $service !== $this->verdict->service) { |
| 668 | throw new \InvalidArgumentException(sprintf( |
| 669 | 'The verified mode is made for the service %s; it does not scan with another one.', |
| 670 | $this->verdict->service, |
| 671 | )); |
| 672 | } |
| 673 | } |
| 674 | |
| 675 | /** |
| 676 | * The headers of a request that carries the whole file without a preview, so `Preview` is not the caller's. In the |
| 677 | * verified mode the request offers 204: only a 204 shows that the service scanned the file, a clean file would |
| 678 | * otherwise come back as a message that looks like one it did not scan. |
| 679 | * |
| 680 | * @param array<string, string|string[]> $extraHeaders |
| 681 | * |
| 682 | * @return array<string, string|string[]> |
| 683 | */ |
| 684 | private function wholeFileHeaders(array $extraHeaders): array |
| 685 | { |
| 686 | $headers = HeaderFields::without($extraHeaders, ['Preview']); |
| 687 | |
| 688 | return $this->verdict === null ? $headers : $this->mergeHeaders($headers, ['Allow' => ['204']]); |
| 689 | } |
| 690 | |
| 691 | /** |
| 692 | * The next response of a session, parsed from its string, or received and dropped by the session if there is a |
| 693 | * budget. |
| 694 | * |
| 695 | * @return \Closure(): IcapResponse |
| 696 | * |
| 697 | * @throws \LogicException When there is a budget and the session cannot discard |
| 698 | */ |
| 699 | private function responseReader(TransportSession $session, ?int $discardBudget): \Closure |
| 700 | { |
| 701 | if ($discardBudget === null) { |
| 702 | return fn (): IcapResponse => $this->parser->parse($session->readResponse()); |
| 703 | } |
| 704 | if (!$session instanceof DiscardingTransportSession) { |
| 705 | throw new \LogicException(sprintf('The session of %s cannot receive a response without keeping it.', $this->transport::class)); |
| 706 | } |
| 707 | |
| 708 | return static fn (): IcapResponse => $session->readResponseDiscardingBody($discardBudget); |
| 709 | } |
| 710 | |
| 711 | /** |
| 712 | * The discarding mode needs a transport that can receive a response without keeping it, and the parser of this |
| 713 | * library: the response is read by the decoder, and another parser would be passed by without a word. |
| 714 | * |
| 715 | * @throws \InvalidArgumentException When the budget is below 1 |
| 716 | * @throws \LogicException When the transport or the parser cannot discard |
| 717 | */ |
| 718 | private function assertCanDiscard(int $maxResponseBytes): void |
| 719 | { |
| 720 | if ($maxResponseBytes < 1) { |
| 721 | throw new \InvalidArgumentException('maxResponseBytes must be >= 1, got: ' . $maxResponseBytes); |
| 722 | } |
| 723 | $this->discardingTransport(); |
| 724 | if (!$this->parser instanceof ResponseParser) { |
| 725 | throw new \LogicException('The discarding mode reads the response with the ResponseParser of this library; ' . $this->parser::class . ' would be passed by.'); |
| 726 | } |
| 727 | } |
| 728 | |
| 729 | /** |
| 730 | * A preview that the server answers with `100 Continue` is continued on the connection it began on, which takes a |
| 731 | * session. A transport without one is refused at the call, before a file is opened or a connection made: sending |
| 732 | * the file again in a request of its own would not be the request the server began with. |
| 733 | * |
| 734 | * @throws \LogicException When the transport has no sessions |
| 735 | */ |
| 736 | private function sessionTransport(): SessionAwareTransport |
| 737 | { |
| 738 | return $this->transport instanceof SessionAwareTransport |
| 739 | ? $this->transport |
| 740 | : throw new \LogicException($this->transport::class . ' cannot continue a preview on the connection it began on (RFC 3507 §4.5); use a transport that implements ' . SessionAwareTransport::class . '.'); |
| 741 | } |
| 742 | |
| 743 | private function discardingTransport(): DiscardingTransport |
| 744 | { |
| 745 | return $this->transport instanceof DiscardingTransport |
| 746 | ? $this->transport |
| 747 | : throw new \LogicException($this->transport::class . ' cannot receive a response without keeping it; use a transport that implements ' . DiscardingTransport::class . '.'); |
| 748 | } |
| 749 | |
| 750 | private const int DEFAULT_PREVIEW_SIZE = 1024; |
| 751 | |
| 752 | /** |
| 753 | * Largest preview the client announces and reads: 1 MiB. A preview is |
| 754 | * read into memory in one piece, so a size taken from the caller or from |
| 755 | * a cached OPTIONS response must never decide an allocation unchecked |
| 756 | * (B06). A limit of this client, not of RFC 3507. |
| 757 | */ |
| 758 | private const int MAX_PREVIEW_SIZE = 1_048_576; |
| 759 | |
| 760 | /** |
| 761 | * Resolve the effective preview size. When the caller passes null, |
| 762 | * the OPTIONS cache is consulted for the server's advertised |
| 763 | * `Preview` header (RFC 3507 §4.10.2). Falls back to |
| 764 | * {@see DEFAULT_PREVIEW_SIZE} when no cache is configured, the |
| 765 | * cache has no usable entry (see {@see cachedOptions()}), or the cached |
| 766 | * response lacks a `Preview` header. |
| 767 | */ |
| 768 | private function resolvePreviewSize(string $service, ?int $previewSize, OperationDeadline $deadline): int |
| 769 | { |
| 770 | if ($previewSize !== null) { |
| 771 | return $previewSize; |
| 772 | } |
| 773 | |
| 774 | $cached = $this->cachedOptions($this->optionsCacheKey($service), $deadline); |
| 775 | $advertised = $cached === null ? null : HeaderFields::single($cached->headers, 'Preview'); |
| 776 | |
| 777 | return $advertised === null ? self::DEFAULT_PREVIEW_SIZE : $this->boundedPreviewSize($advertised); |
| 778 | } |
| 779 | |
| 780 | /** |
| 781 | * The cached OPTIONS answer, or null. A call that is cancelled or over does not ask the cache, and one that ends |
| 782 | * while the cache is asked gets no answer from it. Whatever the cache is, an answer that fails the check of |
| 783 | * {@see OptionsEntry::keeps()} (status, budgets, fields), or that the limits of this client would have refused from |
| 784 | * the server ({@see ResponseDecoder::admits()}), is a miss: the client does not use it. |
| 785 | */ |
| 786 | private function cachedOptions(string $key, OperationDeadline $deadline): ?IcapResponse |
| 787 | { |
| 788 | if ($this->optionsCache === null) { |
| 789 | return null; |
| 790 | } |
| 791 | |
| 792 | $deadline->checkpoint(); |
| 793 | $cached = $this->optionsCache->get($key); |
| 794 | $deadline->checkpoint(); |
| 795 | |
| 796 | return $cached !== null |
| 797 | && OptionsEntry::keeps($cached) |
| 798 | && ResponseDecoder::admits( |
| 799 | $cached, |
| 800 | $this->config->getMaxResponseSize(), |
| 801 | $this->config->getMaxHeaderCount(), |
| 802 | $this->config->getMaxHeaderLineLength(), |
| 803 | ) |
| 804 | ? $cached |
| 805 | : null; |
| 806 | } |
| 807 | |
| 808 | /** |
| 809 | * A `Preview` value from a cached OPTIONS response, which {@see OptionsEntry::keeps()} has shown to be a |
| 810 | * non-negative decimal. The digits are measured before any integer cast, so a value that does not fit an int cannot |
| 811 | * wrap into something else; a value above the limit is clamped to it, and `0` stays `0`. |
| 812 | */ |
| 813 | private function boundedPreviewSize(string $advertised): int |
| 814 | { |
| 815 | $digits = ltrim($advertised, '0'); |
| 816 | if (strlen($digits) > strlen((string) self::MAX_PREVIEW_SIZE)) { |
| 817 | return self::MAX_PREVIEW_SIZE; |
| 818 | } |
| 819 | |
| 820 | return min((int) $digits, self::MAX_PREVIEW_SIZE); |
| 821 | } |
| 822 | |
| 823 | /** |
| 824 | * Read the preview from the start of the opened file and decide from |
| 825 | * what the file itself delivers whether it is the whole body. A preview |
| 826 | * that is complete (`ieof`) needs the end of the file to be observed and |
| 827 | * to match the size it was measured at, not an end inferred from a size |
| 828 | * taken earlier. A failing or stalling read is an error, not a shorter |
| 829 | * preview. |
| 830 | * |
| 831 | * @return array{string, bool} the preview bytes and whether they are the whole body |
| 832 | * |
| 833 | * @throws IcapStorageException When the file cannot be read, or ends at |
| 834 | * another length than it was measured at |
| 835 | */ |
| 836 | private function readPreview(LocalFile $file, int $previewSize): array |
| 837 | { |
| 838 | $fileSize = $file->size; |
| 839 | $complete = $fileSize <= $previewSize; |
| 840 | $wanted = $complete ? $fileSize : $previewSize; |
| 841 | |
| 842 | $bytes = ''; |
| 843 | while (strlen($bytes) < $wanted) { |
| 844 | $block = $file->read(max(1, $wanted - strlen($bytes))); |
| 845 | if ($block === null) { |
| 846 | throw new IcapStorageException(sprintf( |
| 847 | 'The file ended after %d of the %d bytes it was opened with.', |
| 848 | strlen($bytes), |
| 849 | $fileSize, |
| 850 | )); |
| 851 | } |
| 852 | $bytes .= $block; |
| 853 | } |
| 854 | |
| 855 | if ($complete && $file->read(1) !== null) { |
| 856 | throw new IcapStorageException(sprintf( |
| 857 | 'The file holds more than the %d bytes it was opened with.', |
| 858 | $fileSize, |
| 859 | )); |
| 860 | } |
| 861 | |
| 862 | return [$bytes, $complete]; |
| 863 | } |
| 864 | |
| 865 | /** |
| 866 | * Build the ICAP URI for $service on the configured server. Finding H |
| 867 | * and N04: user-controlled service paths could otherwise sneak CR/LF |
| 868 | * into the request line or move the URI authority. The check runs |
| 869 | * before any wire byte has been written. |
| 870 | */ |
| 871 | private function buildServiceUri(string $service): string |
| 872 | { |
| 873 | return IcapUri::forService($this->config->host, $this->config->port, $service); |
| 874 | } |
| 875 | |
| 876 | /** |
| 877 | * The key of the OPTIONS cache entry of a service. One place for |
| 878 | * options() and the preview size lookup, so both name the same entry, |
| 879 | * of this client's security profile. |
| 880 | */ |
| 881 | private function optionsCacheKey(string $service): string |
| 882 | { |
| 883 | return OptionsCacheKey::forService($this->config->host, $this->config->port, $service, $this->config->getTlsContext()); |
| 884 | } |
| 885 | |
| 886 | /** |
| 887 | * The only context the client logs (M16): the method as one of the three |
| 888 | * ICAP methods, or `other`, and the configured port. A URI, a host, a |
| 889 | * service or file path, header and body values and exception messages |
| 890 | * carry what callers and servers put in them, which a log must not keep. |
| 891 | * |
| 892 | * @return array{method: string, port: int} |
| 893 | */ |
| 894 | private function logContext(IcapRequest $request): array |
| 895 | { |
| 896 | return [ |
| 897 | 'method' => in_array($request->method, ['OPTIONS', 'REQMOD', 'RESPMOD'], true) ? $request->method : 'other', |
| 898 | 'port' => $this->config->port, |
| 899 | ]; |
| 900 | } |
| 901 | |
| 902 | /** |
| 903 | * The failure event: the status code of the response if one came in, and |
| 904 | * the class of the exception, without a message, a trace or the exception |
| 905 | * itself. The class of an anonymous class names the file it was written in |
| 906 | * after a NUL byte; that part stays out as well. |
| 907 | * |
| 908 | * @param array{method: string, port: int} $context |
| 909 | */ |
| 910 | private function logFailure(array $context, ?IcapResponse $response, \Throwable $e): void |
| 911 | { |
| 912 | $this->logger->warning('ICAP request failed', $context + [ |
| 913 | 'statusCode' => $response?->statusCode, |
| 914 | 'exception_class' => strstr($e::class, "\0", true) ?: $e::class, |
| 915 | ]); |
| 916 | } |
| 917 | |
| 918 | /** |
| 919 | * Map an ICAP response to a {@see ScanResult} or raise a typed |
| 920 | * exception. Status-code handling follows RFC 3507 §4.3.3 and the |
| 921 | * de-facto vendor conventions (§7 of the consolidated review). |
| 922 | * |
| 923 | * Security note (finding G): 100 Continue is NOT a finish state |
| 924 | * and MUST NOT be mapped to a clean scan. Outside a preview the |
| 925 | * 100 is a protocol error; inside a preview the caller handles it |
| 926 | * via the {@see PreviewStrategyInterface} before this method sees |
| 927 | * the response. |
| 928 | * |
| 929 | * In the verified mode the rules of the {@see CicapVerdict} apply to what is |
| 930 | * left after that; `$complete` says whether the service had the whole file when |
| 931 | * it answered. |
| 932 | */ |
| 933 | private function interpretResponse(IcapResponse $response, Config $config, bool $complete = true): ScanResult |
| 934 | { |
| 935 | $code = $response->statusCode; |
| 936 | |
| 937 | if ($this->verdict !== null) { |
| 938 | // 100 outside a preview, 4xx and 5xx are what they are in every mode; the rest is the verdict's. |
| 939 | $this->assertSuccessfulStatus($code); |
| 940 | |
| 941 | return $this->verdict->interpret($response, $complete); |
| 942 | } |
| 943 | |
| 944 | if ($code === 204) { |
| 945 | return new ScanResult(false, null, $response); |
| 946 | } |
| 947 | |
| 948 | if ($code === 200 || $code === 206) { |
| 949 | // 206 Partial Content — some vendors return this when the |
| 950 | // encapsulated response was modified but not fully rewritten |
| 951 | // (RFC 3507 §4.3.3). Virus signalling is the same as 200. |
| 952 | $virus = $this->extractVirusName($response, $config); |
| 953 | if ($virus !== null) { |
| 954 | return new ScanResult(true, $virus, $response); |
| 955 | } |
| 956 | return new ScanResult(false, null, $response); |
| 957 | } |
| 958 | |
| 959 | $this->assertSuccessfulStatus($code); |
| 960 | |
| 961 | // Fail-secure backstop: any status that is neither a clean-scan |
| 962 | // signal (204/200/206) nor a recognised failure (100/4xx/5xx) — |
| 963 | // e.g. 1xx other than 100, 3xx, 6xx+ — is a protocol violation. |
| 964 | throw new IcapProtocolException( |
| 965 | 'Unexpected ICAP status: ' . $code, |
| 966 | $code, |
| 967 | ); |
| 968 | } |
| 969 | |
| 970 | /** |
| 971 | * The status of an OPTIONS answer from the server: only a 200 |
| 972 | * (RFC 3507 §4.10.2) describes the service. 100, 4xx and 5xx keep their |
| 973 | * types from {@see assertSuccessfulStatus()}; every other status is a |
| 974 | * protocol error: a 204 or 206 belongs to the adaptation of a message, and |
| 975 | * a 1xx, 3xx or 6xx is no answer to OPTIONS at all. What is rejected here |
| 976 | * is neither returned nor cached; a cached answer with another status is a |
| 977 | * miss ({@see cachedOptions()}). Scans keep their own rules in |
| 978 | * {@see interpretResponse()}. |
| 979 | * |
| 980 | * @throws IcapProtocolException When the status is not 200 and not a 4xx or 5xx |
| 981 | * @throws IcapClientException When the status is in the 4xx range |
| 982 | * @throws IcapServerException When the status is in the 5xx range |
| 983 | */ |
| 984 | private function assertOptionsStatus(int $code): void |
| 985 | { |
| 986 | $this->assertSuccessfulStatus($code); |
| 987 | |
| 988 | if ($code !== 200) { |
| 989 | throw new IcapProtocolException('Unexpected ICAP status for OPTIONS: ' . $code, $code); |
| 990 | } |
| 991 | } |
| 992 | |
| 993 | /** |
| 994 | * Apply the fail-secure verdict for status codes that do not signal a |
| 995 | * successful exchange. Used by {@see interpretResponse()} for scans and |
| 996 | * by {@see assertOptionsStatus()} for OPTIONS, which adds its own rule on |
| 997 | * top. |
| 998 | * |
| 999 | * @throws IcapProtocolException When the response is `100 Continue` |
| 1000 | * outside the preview flow |
| 1001 | * @throws IcapClientException When the status is in the 4xx range |
| 1002 | * @throws IcapServerException When the status is in the 5xx range |
| 1003 | */ |
| 1004 | private function assertSuccessfulStatus(int $code): void |
| 1005 | { |
| 1006 | if ($code === 100) { |
| 1007 | throw new IcapProtocolException( |
| 1008 | 'ICAP 100 Continue is only valid during a preview exchange; received outside preview flow.', |
| 1009 | $code, |
| 1010 | ); |
| 1011 | } |
| 1012 | |
| 1013 | $failure = IcapStatus::failure($code); |
| 1014 | if ($failure !== null) { |
| 1015 | throw $failure; |
| 1016 | } |
| 1017 | } |
| 1018 | |
| 1019 | private function extractVirusName(IcapResponse $response, Config $config): ?string |
| 1020 | { |
| 1021 | return HeaderFields::first($response->headers, $config->getVirusFoundHeaders()); |
| 1022 | } |
| 1023 | |
| 1024 | /** |
| 1025 | * Reject header names or values that would break the wire format |
| 1026 | * (Finding H, B04): names must be tokens, values strings without |
| 1027 | * control characters. The formatter applies the same check; running |
| 1028 | * it here as well makes the scan helpers fail before they open the |
| 1029 | * file or start a request. |
| 1030 | * |
| 1031 | * @param array<string, string|string[]> $headers |
| 1032 | */ |
| 1033 | private function validateIcapHeaders(array $headers): void |
| 1034 | { |
| 1035 | HeaderFields::validated($headers, 'ICAP'); |
| 1036 | } |
| 1037 | |
| 1038 | /** |
| 1039 | * Merge caller-supplied headers with library-managed headers. The |
| 1040 | * library-managed map always wins, whatever the spelling of the |
| 1041 | * caller's names, so protocol-critical headers (Preview, Allow) stay |
| 1042 | * out of caller control and go out once. Host and Encapsulated are |
| 1043 | * the formatter's. |
| 1044 | * |
| 1045 | * @param array<string, string|string[]> $caller |
| 1046 | * @param array<string, string[]> $managed |
| 1047 | * @return array<string, string[]> |
| 1048 | */ |
| 1049 | private function mergeHeaders(array $caller, array $managed): array |
| 1050 | { |
| 1051 | $merged = []; |
| 1052 | foreach (HeaderFields::without($caller, array_keys($managed)) as $name => $value) { |
| 1053 | $merged[$name] = (array) $value; |
| 1054 | } |
| 1055 | foreach ($managed as $name => $value) { |
| 1056 | $merged[$name] = $value; |
| 1057 | } |
| 1058 | return $merged; |
| 1059 | } |
| 1060 | } |