Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.70% covered (success)
97.70%
85 / 87
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
DependencyDownloader
97.70% covered (success)
97.70%
85 / 87
75.00% covered (warning)
75.00%
3 / 4
18
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
 download
92.31% covered (success)
92.31%
24 / 26
0.00% covered (danger)
0.00%
0 / 1
7.02
 fetchChecksum
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
6
 verifyDownloadedFile
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\Install;
10
11use OCA\Libresign\Exception\LibresignException;
12use OCP\Http\Client\IClientService;
13use Psr\Log\LoggerInterface;
14
15class DependencyDownloader {
16    public function __construct(
17        private IClientService $clientService,
18        private LoggerInterface $logger,
19    ) {
20    }
21
22    /**
23     * @param (callable(int, int): void)|null $progress
24     */
25    public function download(
26        string $url,
27        string $dependencyName,
28        string $path,
29        string $hash = '',
30        string $hashAlgorithm = 'md5',
31        ?callable $progress = null,
32    ): void {
33        if (file_exists($path) && $hash !== '' && hash_file($hashAlgorithm, $path) === $hash) {
34            if ($progress !== null) {
35                $size = (int)filesize($path);
36                $progress($size, $size);
37            }
38            return;
39        }
40
41        try {
42            $this->clientService->newClient()->get($url, [
43                'sink' => $path,
44                'timeout' => 0,
45                'progress' => static function ($downloadSize, $downloaded) use ($progress): void {
46                    if ($progress !== null) {
47                        $progress((int)$downloadSize, (int)$downloaded);
48                    }
49                },
50            ]);
51        } catch (\Exception $e) {
52            $this->logger->error('Dependency download failed', [
53                'resource' => $dependencyName,
54                'url' => $url,
55                'exception' => $e,
56            ]);
57            throw new LibresignException(
58                'Could not download ' . $dependencyName . '. '
59                . 'Please check the Nextcloud server network, DNS and proxy configuration, then retry. '
60                . 'See the Nextcloud server log for the technical error.',
61                previous: $e,
62            );
63        }
64
65        $this->verifyDownloadedFile($url, $dependencyName, $path, $hash, $hashAlgorithm);
66    }
67
68    public function fetchChecksum(string $file, string $checksumUrl): string {
69        try {
70            $response = $this->clientService->newClient()->get($checksumUrl);
71            $hashes = $response->getBody();
72        } catch (\Exception $e) {
73            $this->logger->error('Dependency checksum file download failed', [
74                'url' => $checksumUrl,
75                'file' => $file,
76                'exception' => $e,
77            ]);
78            throw new LibresignException(
79                'Could not download the checksum information required to verify ' . $file . '. '
80                . 'Please check the server network, DNS and proxy configuration, then retry. '
81                . 'See the Nextcloud server log for the technical error.',
82                previous: $e,
83            );
84        }
85
86        if (!is_string($hashes) || $hashes === '') {
87            $this->logger->error('Dependency checksum file is empty', [
88                'url' => $checksumUrl,
89                'file' => $file,
90            ]);
91            throw new LibresignException(
92                'The checksum information for ' . $file . ' was empty. '
93                . 'Please retry later. If the problem persists, check the Nextcloud server log before reporting it.',
94            );
95        }
96
97        $matched = preg_match(
98            '/(?<hash>[A-Fa-f0-9]+) +' . preg_quote($file, '/') . '(?:\s|$)/',
99            $hashes,
100            $matches,
101        );
102        if ($matched !== 1 || empty($matches['hash'])) {
103            $this->logger->error('Checksum entry not found for dependency artifact', [
104                'url' => $checksumUrl,
105                'file' => $file,
106            ]);
107            throw new LibresignException(
108                'The checksum list does not contain an entry for ' . $file . '. '
109                . 'This usually indicates that the upstream release metadata changed or is temporarily incomplete. '
110                . 'Please retry later and check the Nextcloud server log if it persists.',
111            );
112        }
113
114        return $matches['hash'];
115    }
116
117    private function verifyDownloadedFile(
118        string $url,
119        string $dependencyName,
120        string $path,
121        string $hash,
122        string $hashAlgorithm,
123    ): void {
124        if (!file_exists($path)) {
125            $this->logger->error('Dependency download completed without creating the expected file', [
126                'resource' => $dependencyName,
127                'url' => $url,
128                'path' => $path,
129            ]);
130            throw new LibresignException(
131                'Download of ' . $dependencyName . ' did not produce the expected file. '
132                . 'Please retry the installation. If the problem persists, check the Nextcloud server log for details.',
133            );
134        }
135
136        if ($hash !== '' && hash_file($hashAlgorithm, $path) !== $hash) {
137            $this->logger->error('Dependency checksum verification failed', [
138                'resource' => $dependencyName,
139                'url' => $url,
140                'path' => $path,
141                'algorithm' => $hashAlgorithm,
142            ]);
143            throw new LibresignException(
144                'Checksum verification failed for ' . $dependencyName . '. The downloaded file was not accepted. '
145                . 'Please retry the installation. If it fails again, check whether a proxy or cache is modifying downloads and review the Nextcloud server log.',
146            );
147        }
148    }
149}