Resolve language item-related PIP GUI todos
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / lib / system / package / validation / PackageValidationArchive.class.php
1 <?php
2 namespace wcf\system\package\validation;
3 use wcf\data\package\Package;
4 use wcf\data\package\PackageList;
5 use wcf\system\database\util\PreparedStatementConditionBuilder;
6 use wcf\system\package\PackageArchive;
7 use wcf\system\WCF;
8
9 /**
10 * Recursively validates the package archive and it's delivered requirements.
11 *
12 * @author Alexander Ebert
13 * @copyright 2001-2018 WoltLab GmbH
14 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
15 * @package WoltLabSuite\Core\System\Package\Validation
16 */
17 class PackageValidationArchive implements \RecursiveIterator {
18 /**
19 * list of excluded packages grouped by package
20 * @var string[][]
21 */
22 protected static $excludedPackages = [];
23
24 /**
25 * package archive object
26 * @var PackageArchive
27 */
28 protected $archive;
29
30 /**
31 * list of direct requirements delivered by this package
32 * @var PackageValidationArchive[]
33 */
34 protected $children = [];
35
36 /**
37 * nesting depth
38 * @var integer
39 */
40 protected $depth = 0;
41
42 /**
43 * exception occurred during validation
44 * @var \Exception
45 */
46 protected $exception;
47
48 /**
49 * associated package object
50 * @var Package
51 */
52 protected $package;
53
54 /**
55 * parent package validation archive object
56 * @var PackageValidationArchive
57 */
58 protected $parent;
59
60 /**
61 * children pointer
62 * @var integer
63 */
64 private $position = 0;
65
66 /**
67 * Creates a new package validation archive instance.
68 *
69 * @param string $archive
70 * @param PackageValidationArchive $parent
71 * @param integer $depth
72 */
73 public function __construct($archive, PackageValidationArchive $parent = null, $depth = 0) {
74 $this->archive = new PackageArchive($archive);
75 $this->parent = $parent;
76 $this->depth = $depth;
77 }
78
79 /**
80 * Validates this package and optionally it's delivered requirements. The set validation
81 * mode will toggle between different checks.
82 *
83 * @param integer $validationMode
84 * @param string $requiredVersion
85 * @return boolean
86 */
87 public function validate($validationMode, $requiredVersion = '') {
88 if ($validationMode !== PackageValidationManager::VALIDATION_EXCLUSION) {
89 try {
90 // try to read archive
91 $this->archive->openArchive();
92
93 // check if package is installable or suitable for an update
94 $this->validateInstructions($requiredVersion, $validationMode);
95 }
96 catch (PackageValidationException $e) {
97 $this->exception = $e;
98
99 return false;
100 }
101 }
102
103 $package = $this->archive->getPackageInfo('name');
104
105 if ($validationMode === PackageValidationManager::VALIDATION_RECURSIVE) {
106 try {
107 PackageValidationManager::getInstance()->addVirtualPackage($package, $this->archive->getPackageInfo('version'));
108
109 // cache excluded packages
110 self::$excludedPackages[$package] = [];
111 $excludedPackages = $this->archive->getExcludedPackages();
112 for ($i = 0, $count = count($excludedPackages); $i < $count; $i++) {
113 if (!isset(self::$excludedPackages[$package][$excludedPackages[$i]['name']])) {
114 self::$excludedPackages[$package][$excludedPackages[$i]['name']] = [];
115 }
116
117 self::$excludedPackages[$package][$excludedPackages[$i]['name']][] = $excludedPackages[$i]['version'];
118 }
119
120 // traverse open requirements
121 foreach ($this->archive->getOpenRequirements() as $requirement) {
122 $virtualPackageVersion = PackageValidationManager::getInstance()->getVirtualPackage($requirement['name']);
123 if ($virtualPackageVersion === null || Package::compareVersion($virtualPackageVersion, $requirement['minversion'], '<')) {
124 if (empty($requirement['file'])) {
125 // check if package is known
126 $sql = "SELECT *
127 FROM wcf".WCF_N."_package
128 WHERE package = ?";
129 $statement = WCF::getDB()->prepareStatement($sql);
130 $statement->execute([$requirement['name']]);
131 $package = $statement->fetchObject(Package::class);
132
133 throw new PackageValidationException(PackageValidationException::MISSING_REQUIREMENT, [
134 'package' => $package,
135 'packageName' => $requirement['name'],
136 'packageVersion' => $requirement['minversion']
137 ]);
138 }
139
140 $archive = $this->archive->extractTar($requirement['file']);
141
142 $index = count($this->children);
143 $this->children[$index] = new PackageValidationArchive($archive, $this, $this->depth + 1);
144 if (!$this->children[$index]->validate(PackageValidationManager::VALIDATION_RECURSIVE, $requirement['minversion'])) {
145 return false;
146 }
147
148 PackageValidationManager::getInstance()->addVirtualPackage(
149 $this->children[$index]->getArchive()->getPackageInfo('name'),
150 $this->children[$index]->getArchive()->getPackageInfo('version')
151 );
152 }
153 }
154 }
155 catch (PackageValidationException $e) {
156 $this->exception = $e;
157
158 return false;
159 }
160 }
161 else if ($validationMode === PackageValidationManager::VALIDATION_EXCLUSION) {
162 try {
163 $this->validateExclusion($package);
164
165 for ($i = 0, $count = count($this->children); $i < $count; $i++) {
166 if (!$this->children[$i]->validate(PackageValidationManager::VALIDATION_EXCLUSION)) {
167 return false;
168 }
169 }
170 }
171 catch (PackageValidationException $e) {
172 $this->exception = $e;
173
174 return false;
175 }
176 }
177
178 return true;
179
180 }
181
182 /**
183 * Validates if the package has suitable install or update instructions
184 *
185 * @param string $requiredVersion
186 * @param integer $validationMode
187 * @throws PackageValidationException
188 */
189 protected function validateInstructions($requiredVersion, $validationMode) {
190 $package = $this->getPackage();
191
192 // delivered package does not provide the minimum required version
193 if (Package::compareVersion($requiredVersion, $this->archive->getPackageInfo('version'), '>')) {
194 throw new PackageValidationException(PackageValidationException::INSUFFICIENT_VERSION, [
195 'packageName' => $this->archive->getPackageInfo('name'),
196 'packageVersion' => $requiredVersion,
197 'deliveredPackageVersion' => $this->archive->getPackageInfo('version')
198 ]);
199 }
200
201 // check if this package exposes compatible api versions
202 $compatibleVersions = $this->archive->getCompatibleVersions();
203 if (!empty($compatibleVersions)) {
204 $isCompatible = $isOlderVersion = false;
205 foreach ($compatibleVersions as $version) {
206 if (WCF::isSupportedApiVersion($version)) {
207 $isCompatible = true;
208 break;
209 }
210 else if ($version < WSC_API_VERSION) {
211 $isOlderVersion = true;
212 }
213 }
214
215 if (!$isCompatible) {
216 throw new PackageValidationException(PackageValidationException::INCOMPATIBLE_API_VERSION, ['isOlderVersion' => $isOlderVersion]);
217 }
218 }
219 else if (ENABLE_DEBUG_MODE && ENABLE_DEVELOPER_TOOLS && ($package === null || $package->package !== 'com.woltlab.wcf')) {
220 throw new PackageValidationException(PackageValidationException::MISSING_API_VERSION);
221 }
222
223 // package is not installed yet
224 if ($package === null) {
225 $instructions = $this->archive->getInstallInstructions();
226 if (empty($instructions)) {
227 throw new PackageValidationException(PackageValidationException::NO_INSTALL_PATH, ['packageName' => $this->archive->getPackageInfo('name')]);
228 }
229
230 if ($validationMode == PackageValidationManager::VALIDATION_RECURSIVE) {
231 $this->validatePackageInstallationPlugins('install', $instructions);
232 }
233 }
234 else {
235 // package is already installed, check update path
236 if (!$this->archive->isValidUpdate($package)) {
237 $deliveredPackageVersion = $this->archive->getPackageInfo('version');
238
239 // check if the package is already installed with the same exact version
240 if ($package->packageVersion === $deliveredPackageVersion) {
241 throw new PackageValidationException(PackageValidationException::ALREADY_INSTALLED, [
242 'packageName' => $package->packageName,
243 'packageVersion' => $package->packageVersion
244 ]);
245 }
246 else {
247 throw new PackageValidationException(PackageValidationException::NO_UPDATE_PATH, [
248 'packageName' => $package->packageName,
249 'packageVersion' => $package->packageVersion,
250 'deliveredPackageVersion' => $deliveredPackageVersion
251 ]);
252 }
253 }
254
255 if ($validationMode === PackageValidationManager::VALIDATION_RECURSIVE) {
256 $this->validatePackageInstallationPlugins('update', $this->archive->getUpdateInstructions());
257 }
258 }
259 }
260
261 /**
262 * Validates install or update instructions against the corresponding PIP, unknown PIPs will be silently ignored.
263 *
264 * @param string $type
265 * @param mixed[][] $instructions
266 * @throws PackageValidationException
267 */
268 protected function validatePackageInstallationPlugins($type, array $instructions) {
269 for ($i = 0, $length = count($instructions); $i < $length; $i++) {
270 $instruction = $instructions[$i];
271 if (!PackageValidationManager::getInstance()->validatePackageInstallationPluginInstruction($this->archive, $instruction['pip'], $instruction['value'])) {
272 $defaultFilename = PackageValidationManager::getInstance()->getDefaultFilenameForPackageInstallationPlugin($instruction['pip']);
273
274 throw new PackageValidationException(PackageValidationException::MISSING_INSTRUCTION_FILE, [
275 'pip' => $instruction['pip'],
276 'type' => $type,
277 'value' => $instruction['value'] ?: $defaultFilename
278 ]);
279 }
280 }
281 }
282
283 /**
284 * Validates if an installed package excludes the current package and vice versa.
285 *
286 * @param string $package
287 * @throws PackageValidationException
288 */
289 protected function validateExclusion($package) {
290 $packageVersion = $this->archive->getPackageInfo('version');
291
292 // excluding packages: installed -> current
293 $sql = "SELECT package.*, package_exclusion.*
294 FROM wcf".WCF_N."_package_exclusion package_exclusion
295 LEFT JOIN wcf".WCF_N."_package package
296 ON (package.packageID = package_exclusion.packageID)
297 WHERE excludedPackage = ?";
298 $statement = WCF::getDB()->prepareStatement($sql);
299 $statement->execute([$this->getArchive()->getPackageInfo('name')]);
300 $excludingPackages = [];
301 while ($row = $statement->fetchArray()) {
302 $excludingPackage = $row['package'];
303
304 // use exclusions of queued package
305 if (isset(self::$excludedPackages[$excludingPackage])) {
306 if (isset(self::$excludedPackages[$excludingPackage][$package])) {
307 for ($i = 0, $count = count(self::$excludedPackages[$excludingPackage][$package]); $i < $count; $i++) {
308 if (Package::compareVersion($packageVersion, self::$excludedPackages[$excludingPackage][$package][$i], '<')) {
309 continue;
310 }
311
312 $excludingPackages[] = new Package(null, $row);
313 }
314
315 continue;
316 }
317 }
318 else {
319 if (Package::compareVersion($packageVersion, $row['excludedPackageVersion'], '<')) {
320 continue;
321 }
322
323 $excludingPackages[] = new Package(null, $row);
324 }
325 }
326
327 if (!empty($excludingPackages)) {
328 throw new PackageValidationException(PackageValidationException::EXCLUDING_PACKAGES, ['packages' => $excludingPackages]);
329 }
330
331 // excluded packages: current -> installed
332 if (!empty(self::$excludedPackages[$package])) {
333 // get installed packages
334 $conditions = new PreparedStatementConditionBuilder();
335 $conditions->add("package IN (?)", [array_keys(self::$excludedPackages[$package])]);
336 $sql = "SELECT *
337 FROM wcf".WCF_N."_package
338 ".$conditions;
339 $statement = WCF::getDB()->prepareStatement($sql);
340 $statement->execute($conditions->getParameters());
341 $packages = [];
342 while ($row = $statement->fetchArray()) {
343 $packages[$row['package']] = new Package(null, $row);
344 }
345
346 $excludedPackages = [];
347 foreach ($packages as $excludedPackage => $packageObj) {
348 $version = PackageValidationManager::getInstance()->getVirtualPackage($excludedPackage);
349 if ($version === null) {
350 $version = $packageObj->packageVersion;
351 }
352
353 for ($i = 0, $count = count(self::$excludedPackages[$package][$excludedPackage]); $i < $count; $i++) {
354 if (Package::compareVersion($version, self::$excludedPackages[$package][$excludedPackage][$i], '<')) {
355 continue;
356 }
357
358 $excludedPackages[] = $packageObj;
359 }
360 }
361
362 if (!empty($excludedPackages)) {
363 throw new PackageValidationException(PackageValidationException::EXCLUDED_PACKAGES, ['packages' => $excludedPackages]);
364 }
365 }
366 }
367
368 /**
369 * Returns the occurred exception.
370 *
371 * @return \Exception
372 */
373 public function getException() {
374 return $this->exception;
375 }
376
377 /**
378 * Returns the exception message.
379 *
380 * @return string
381 */
382 public function getExceptionMessage() {
383 if ($this->exception === null) {
384 return '';
385 }
386
387 if ($this->exception instanceof PackageValidationException) {
388 return $this->exception->getErrorMessage();
389 }
390
391 return $this->exception->getMessage();
392 }
393
394 /**
395 * Returns the package archive object.
396 *
397 * @return PackageArchive
398 */
399 public function getArchive() {
400 return $this->archive;
401 }
402
403 /**
404 * Returns the package object based on the package archive's package identifier or null
405 * if the package isn't already installed.
406 *
407 * @return Package
408 */
409 public function getPackage() {
410 if ($this->package === null) {
411 static $packages;
412 if ($packages === null) {
413 $packages = [];
414
415 // Do not rely on PackageCache here, it may be outdated if a previous installation of a package has failed
416 // and the user attempts to install it again in a secondary browser tab!
417 $packageList = new PackageList();
418 $packageList->readObjects();
419 foreach ($packageList as $package) {
420 $packages[$package->package] = $package;
421 }
422 }
423
424 $identifier = $this->archive->getPackageInfo('name');
425 if (isset($packages[$identifier])) {
426 $this->package = $packages[$identifier];
427 }
428 }
429
430 return $this->package;
431 }
432
433 /**
434 * Returns nesting depth.
435 *
436 * @return integer
437 */
438 public function getDepth() {
439 return $this->depth;
440 }
441
442 /**
443 * Sets the children of this package validation archive.
444 *
445 * @param PackageValidationArchive[] $children
446 */
447 public function setChildren(array $children) {
448 $this->children = $children;
449 }
450
451 /**
452 * @inheritDoc
453 */
454 public function rewind() {
455 $this->position = 0;
456 }
457
458 /**
459 * @inheritDoc
460 */
461 public function valid() {
462 return isset($this->children[$this->position]);
463 }
464
465 /**
466 * @inheritDoc
467 */
468 public function next() {
469 $this->position++;
470 }
471
472 /**
473 * @inheritDoc
474 */
475 public function current() {
476 return $this->children[$this->position];
477 }
478
479 /**
480 * @inheritDoc
481 */
482 public function key() {
483 return $this->position;
484 }
485
486 /**
487 * @inheritDoc
488 */
489 public function getChildren() {
490 return $this->children[$this->position];
491 }
492
493 /**
494 * @inheritDoc
495 */
496 public function hasChildren() {
497 return count($this->children) > 0;
498 }
499 }