Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
96.97% |
64 / 66 |
|
84.62% |
11 / 13 |
CRAP | |
0.00% |
0 / 1 |
PackageManager | |
96.97% |
64 / 66 |
|
84.62% |
11 / 13 |
51 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
extract | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
load | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
isValid | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
hashFiles | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
install | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
download | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
move | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
copy | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
5 | |||
delete | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
cmd | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
10.20 | |||
cleanup | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
authenticate | |
80.00% |
4 / 5 |
|
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 | */ |
13 | declare(strict_types=1); |
14 | |
15 | namespace phpOMS\Module; |
16 | |
17 | use phpOMS\System\File\Local\Directory; |
18 | use phpOMS\System\File\Local\File; |
19 | use phpOMS\System\File\Local\LocalStorage; |
20 | use phpOMS\System\File\PathException; |
21 | use phpOMS\System\OperatingSystem; |
22 | use phpOMS\System\SystemType; |
23 | use phpOMS\Utils\IO\Zip\Zip; |
24 | use 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 | */ |
36 | final 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 | } |