Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.97% covered (success)
96.97%
64 / 66
84.62% covered (warning)
84.62%
11 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
PackageManager
96.97% covered (success)
96.97%
64 / 66
84.62% covered (warning)
84.62%
11 / 13
51
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 extract
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 load
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 isValid
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 hashFiles
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 install
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 download
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 move
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 copy
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 delete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 cmd
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
10.20
 cleanup
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 authenticate
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
5.20
1<?php
2/**
3 * Jingga
4 *
5 * PHP Version 8.1
6 *
7 * @package   phpOMS\Module
8 * @copyright Dennis Eichhorn
9 * @license   OMS License 2.0
10 * @version   1.0.0
11 * @link      https://jingga.app
12 */
13declare(strict_types=1);
14
15namespace phpOMS\Module;
16
17use phpOMS\System\File\Local\Directory;
18use phpOMS\System\File\Local\File;
19use phpOMS\System\File\Local\LocalStorage;
20use phpOMS\System\File\PathException;
21use phpOMS\System\OperatingSystem;
22use phpOMS\System\SystemType;
23use phpOMS\Utils\IO\Zip\Zip;
24use phpOMS\Utils\StringUtils;
25
26/**
27 * Package Manager model.
28 *
29 * The package manager is responsible for handling installation and update packages for modules, frameworks and resources.
30 *
31 * @package phpOMS\Module
32 * @license OMS License 2.0
33 * @link    https://jingga.app
34 * @since   1.0.0
35 */
36final class PackageManager
37{
38    /**
39     * File path.
40     *
41     * @var string
42     * @since 1.0.0
43     */
44    private string $path = '';
45
46    /**
47     * Base path.
48     *
49     * @var string
50     * @since 1.0.0
51     */
52    private string $basePath = '';
53
54    /**
55     * Extract path.
56     *
57     * @var string
58     * @since 1.0.0
59     */
60    private string $extractPath = '';
61
62    /**
63     * Public key.
64     *
65     * @var string
66     * @since 1.0.0
67     */
68    private string $publicKey = '';
69
70    /**
71     * Info data.
72     *
73     * @var array
74     * @since 1.0.0
75     */
76    private array $info = [];
77
78    /**
79     * Constructor.
80     *
81     * @param string $path      Package source path e.g. path after download.
82     * @param string $basePath  Path of the application
83     * @param string $publicKey Public key
84     *
85     * @since 1.0.0
86     */
87    public function __construct(string $path, string $basePath, string $publicKey)
88    {
89        $this->path      = $path;
90        $this->basePath  = \rtrim($basePath, '\\/');
91        $this->publicKey = $publicKey;
92    }
93
94    /**
95     * Extract package to temporary destination
96     *
97     * @param string $path Temporary extract path
98     *
99     * @return void
100     *
101     * @since 1.0.0
102     */
103    public function extract(string $path) : void
104    {
105        $this->extractPath = \rtrim($path, '\\/');
106        Zip::unpack($this->path, $this->extractPath);
107    }
108
109    /**
110     * Load info data from path.
111     *
112     * @return void
113     *
114     * @throws PathException this exception is thrown in case the info file path doesn't exist
115     *
116     * @since 1.0.0
117     */
118    public function load() : void
119    {
120        if (!\is_dir($this->extractPath)) {
121            throw new PathException($this->extractPath);
122        }
123
124        $contents   = \file_get_contents($this->extractPath . '/info.json');
125        $info       = \json_decode($contents === false ? '[]' : $contents, true);
126        $this->info = \is_array($info) ? $info : [];
127    }
128
129    /**
130     * Validate package integrity
131     *
132     * @return bool Returns true if the package is authentic, false otherwise
133     *
134     * @since 1.0.0
135     */
136    public function isValid() : bool
137    {
138        if (!\is_file($this->extractPath . '/package.cert')) {
139            return false;
140        }
141
142        $contents = \file_get_contents($this->extractPath . '/package.cert');
143        return $this->authenticate($contents === false ? '' : $contents, $this->hashFiles());
144    }
145
146    /**
147     * Hash array of files
148     *
149     * @return string Hash value of files
150     *
151     * @throws \Exception
152     *
153     * @since 1.0.0
154     */
155    private function hashFiles() : string
156    {
157        $files = Directory::list($this->extractPath, '*', true);
158        $state = \sodium_crypto_generichash_init();
159
160        foreach ($files as $file) {
161            if ($file === 'package.cert' || \is_dir($this->extractPath . '/' . $file)) {
162                continue;
163            }
164
165            $contents = \file_get_contents($this->extractPath . '/' . $file);
166            if ($contents === false) {
167                throw new \Exception(); // @codeCoverageIgnore
168            }
169
170            \sodium_crypto_generichash_update($state, $contents);
171        }
172
173        return \sodium_crypto_generichash_final($state);
174    }
175
176    /**
177     * Install package
178     *
179     * @return void
180     *
181     * @throws \Exception
182     *
183     * @since 1.0.0
184     */
185    public function install() : void
186    {
187        if (!$this->isValid()) {
188            throw new \Exception();
189        }
190
191        foreach ($this->info['update'] as $steps) {
192            foreach ($steps as $key => $components) {
193                if (\method_exists($this, $key)) {
194                    $this->{$key}($components);
195                }
196            }
197        }
198    }
199
200    /**
201     * Download files
202     *
203     * @param array<string, string> $components Component data
204     *
205     * @return void
206     *
207     * @since 1.0.0
208     */
209    private function download(array $components) : void
210    {
211        foreach ($components as $from => $to) {
212            $fp = \fopen($this->basePath . '/' . $to, 'w+');
213            $ch = \curl_init(\str_replace(' ','%20', $from));
214
215            if ($ch === false || $fp === false) {
216                continue; // @codeCoverageIgnore
217            }
218
219            \curl_setopt($ch, \CURLOPT_TIMEOUT, 50);
220            \curl_setopt($ch, \CURLOPT_FILE, $fp);
221            \curl_setopt($ch, \CURLOPT_FOLLOWLOCATION, true);
222
223            \curl_exec($ch);
224
225            \curl_close($ch);
226            \fclose($fp);
227        }
228    }
229
230    /**
231     * Move files
232     *
233     * @param array<string, string> $components Component data
234     *
235     * @return void
236     *
237     * @since 1.0.0
238     */
239    private function move(array $components) : void
240    {
241        foreach ($components as $from => $to) {
242            $fromPath = StringUtils::startsWith($from, '/Package/') ? $this->extractPath . '/' . \substr($from, 9) : $this->basePath . '/' . $from;
243            $toPath   = StringUtils::startsWith($to, '/Package/') ? $this->extractPath . '/' . \substr($to, 9) : $this->basePath . '/' . $to;
244
245            LocalStorage::move($fromPath, $toPath, true);
246        }
247    }
248
249    /**
250     * Copy files
251     *
252     * @param array<string, array<int, string>> $components Component data
253     *
254     * @return void
255     *
256     * @since 1.0.0
257     */
258    private function copy(array $components) : void
259    {
260        foreach ($components as $from => $tos) {
261            $fromPath = StringUtils::startsWith($from, '/Package/') ? $this->extractPath . '/' . \substr($from, 9) : $this->basePath . '/' . $from;
262
263            foreach ($tos as $to) {
264                $toPath = StringUtils::startsWith($to, '/Package/') ? $this->extractPath . '/' . \substr($to, 9) : $this->basePath . '/' . $to;
265
266                LocalStorage::copy($fromPath, $toPath, true);
267            }
268        }
269    }
270
271    /**
272     * Delete files
273     *
274     * @param string[] $components Component data
275     *
276     * @return void
277     *
278     * @since 1.0.0
279     */
280    private function delete(array $components) : void
281    {
282        foreach ($components as $component) {
283            $path = StringUtils::startsWith($component, '/Package/') ? $this->extractPath . '/' . \substr($component, 9) : $this->basePath . '/' . $component;
284            LocalStorage::delete($path);
285        }
286    }
287
288    /**
289     * Execute commands
290     *
291     * @param string[] $components Component data
292     *
293     * @return void
294     *
295     * @since 1.0.0
296     */
297    private function cmd(array $components) : void
298    {
299        foreach ($components as $component) {
300            $cmd  = '';
301            $path = StringUtils::startsWith($component, '/Package/') ? $this->extractPath . '/' . \substr($component, 9) : $this->basePath . '/' . $component;
302
303            if (StringUtils::endsWith($component, '.php')) {
304                $cmd = 'php ' . $path;
305            } elseif ((StringUtils::endsWith($component, '.sh') && OperatingSystem::getSystem() === SystemType::LINUX && \is_executable($path))
306                || (StringUtils::endsWith($component, '.batch') && OperatingSystem::getSystem() === SystemType::WIN && \is_executable($path))
307            ) {
308                $cmd = $path;
309            }
310
311            /*
312            if ($cmd !== '') {
313                // @todo implement
314            }
315            */
316        }
317    }
318
319    /**
320     * Cleanup after installation
321     *
322     * @return void
323     *
324     * @since 1.0.0
325     */
326    public function cleanup() : void
327    {
328        File::delete($this->path);
329        Directory::delete($this->extractPath);
330    }
331
332    /**
333     * Authenticate package
334     *
335     * @param string $signedHash Hash to authenticate
336     * @param string $rawHash    Hash to compare against
337     *
338     * @return bool
339     *
340     * @since 1.0.0
341     */
342    private function authenticate(string $signedHash, string $rawHash) : bool
343    {
344        if ($signedHash === '' || $rawHash === '' || $this->publicKey === '') {
345            return false;
346        }
347
348        try {
349            return \sodium_crypto_sign_verify_detached($signedHash, $rawHash, $this->publicKey);
350        } catch(\Throwable $_) {
351            return false;
352        }
353    }
354}