Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.31% covered (warning)
67.31%
35 / 52
37.50% covered (danger)
37.50%
3 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
IcapClient
67.31% covered (warning)
67.31%
35 / 52
37.50% covered (danger)
37.50%
3 / 8
22.86
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 forServer
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 create
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 request
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 options
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 scanFile
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 scanFileWithPreview
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
3.00
 interpretResponse
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
1<?php
2
3declare(strict_types=1);
4
5namespace Ndrstmr\Icap;
6
7use Amp\Future;
8use Ndrstmr\Icap\DTO\IcapRequest;
9use Ndrstmr\Icap\DTO\IcapResponse;
10use Ndrstmr\Icap\DTO\ScanResult;
11use Ndrstmr\Icap\Exception\IcapResponseException;
12use Ndrstmr\Icap\Transport\TransportInterface;
13use Ndrstmr\Icap\Transport\SynchronousStreamTransport;
14use Ndrstmr\Icap\Transport\AsyncAmpTransport;
15use Ndrstmr\Icap\RequestFormatter;
16use Ndrstmr\Icap\ResponseParser;
17use Ndrstmr\Icap\RequestFormatterInterface;
18use Ndrstmr\Icap\ResponseParserInterface;
19use Ndrstmr\Icap\PreviewStrategyInterface;
20use Ndrstmr\Icap\DefaultPreviewStrategy;
21
22/**
23 * Core asynchronous ICAP client used by the synchronous wrapper.
24 */
25class IcapClient
26{
27    /**
28     * @param Config                       $config          Connection configuration
29     * @param TransportInterface            $transport       Transport implementation
30     * @param RequestFormatterInterface     $formatter       Formats outgoing requests
31     * @param ResponseParserInterface       $parser          Parses incoming responses
32     * @param PreviewStrategyInterface|null $previewStrategy Strategy for preview handling
33     */
34    public function __construct(
35        private Config $config,
36        private TransportInterface $transport,
37        private RequestFormatterInterface $formatter,
38        private ResponseParserInterface $parser,
39        ?PreviewStrategyInterface $previewStrategy = null
40    ) {
41        $this->previewStrategy = $previewStrategy ?? new DefaultPreviewStrategy();
42    }
43
44    private PreviewStrategyInterface $previewStrategy;
45
46    /**
47     * Convenience factory for synchronous environments.
48     */
49    public static function forServer(string $host, int $port = 1344): self
50    {
51        return new self(new Config($host, $port), new SynchronousStreamTransport(), new RequestFormatter(), new ResponseParser());
52    }
53
54    /**
55     * Factory using the default async transport.
56     */
57    public static function create(): self
58    {
59        return new self(
60            new Config('127.0.0.1'),
61            new AsyncAmpTransport(),
62            new RequestFormatter(),
63            new ResponseParser(),
64            new DefaultPreviewStrategy(),
65        );
66    }
67
68    /**
69     * Send a raw ICAP request.
70     *
71     * @param IcapRequest $request
72     * @return Future<ScanResult>
73     */
74    public function request(IcapRequest $request): Future
75    {
76        /** @var Future<ScanResult> $future */
77        $future = \Amp\async(function () use ($request): ScanResult {
78            $raw = $this->formatter->format($request);
79            $responseString = $this->transport->request($this->config, $raw)->await();
80
81            $response = $this->parser->parse($responseString);
82
83            return $this->interpretResponse($response, $this->config);
84        });
85
86        return $future;
87    }
88
89    /**
90     * Issue an OPTIONS request to the given service.
91     *
92     * @param string $service
93     * @return Future<ScanResult>
94     */
95    public function options(string $service): Future
96    {
97        $uri = sprintf('icap://%s%s', $this->config->host, $service);
98        $request = new IcapRequest('OPTIONS', $uri);
99        return $this->request($request);
100    }
101
102    /**
103     * Scan a local file via RESPMOD.
104     *
105     * @param string $service
106     * @param string $filePath
107     * @return Future<ScanResult>
108     * @throws \RuntimeException When the file cannot be opened
109     */
110    public function scanFile(string $service, string $filePath): Future
111    {
112        $stream = fopen($filePath, 'r');
113        if ($stream === false) {
114            throw new \RuntimeException('Unable to open file');
115        }
116        $uri = sprintf('icap://%s%s', $this->config->host, $service);
117        $request = new IcapRequest('RESPMOD', $uri, [], $stream);
118        return $this->request($request);
119    }
120
121    /**
122     * Scan a file using preview mode.
123     *
124     * @param string $service
125     * @param string $filePath
126     * @param int    $previewSize
127     * @return Future<ScanResult>
128     * @throws \RuntimeException When the file cannot be read
129     */
130    public function scanFileWithPreview(string $service, string $filePath, int $previewSize = 1024): Future
131    {
132        /** @var Future<ScanResult> $future */
133        $future = \Amp\async(function () use ($service, $filePath, $previewSize): ScanResult {
134            $content = file_get_contents($filePath);
135            if ($content === false) {
136                throw new \RuntimeException('Unable to read file');
137            }
138
139            $uri = sprintf('icap://%s%s', $this->config->host, $service);
140
141            $previewBody = substr($content, 0, $previewSize);
142            $previewReq = new IcapRequest('RESPMOD', $uri, ['Preview' => [(string) $previewSize]], $previewBody);
143            $previewResult = $this->request($previewReq)->await();
144            $decision = $this->previewStrategy->handlePreviewResponse($previewResult->getOriginalResponse());
145
146            if ($decision === PreviewDecision::CONTINUE_SENDING) {
147                $remaining = substr($content, $previewSize);
148                $finalReq = new IcapRequest('RESPMOD', $uri, [], $remaining);
149                return $this->request($finalReq)->await();
150            }
151
152            return $previewResult;
153        });
154
155        return $future;
156    }
157
158    private function interpretResponse(IcapResponse $response, Config $config): ScanResult
159    {
160        if ($response->statusCode === 204) {
161            return new ScanResult(false, null, $response);
162        }
163
164        if ($response->statusCode === 200) {
165            $header = $config->getVirusFoundHeader();
166            $virus = $response->headers[$header][0] ?? null;
167
168            if ($virus !== null) {
169                return new ScanResult(true, $virus, $response);
170            }
171
172            return new ScanResult(false, null, $response);
173        }
174
175        if ($response->statusCode === 100) {
176            return new ScanResult(false, null, $response);
177        }
178
179        throw new IcapResponseException('Unexpected ICAP status: ' . $response->statusCode, $response->statusCode);
180    }
181}