Passed
Pull Request — master (#391)
by Théo
02:24
created

Box::count()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 2
dl 0
loc 5
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the box project.
7
 *
8
 * (c) Kevin Herrera <[email protected]>
9
 *     Théo Fidry <[email protected]>
10
 *
11
 * This source file is subject to the MIT license that is bundled
12
 * with this source code in the file LICENSE.
13
 */
14
15
namespace KevinGH\Box;
16
17
use function Amp\ParallelFunctions\parallelMap;
18
use function Amp\Promise\wait;
19
use function array_filter;
20
use function array_flip;
21
use function array_map;
22
use function array_unshift;
23
use Assert\Assertion;
24
use BadMethodCallException;
25
use function chdir;
26
use Countable;
27
use function dirname;
28
use function extension_loaded;
29
use function file_exists;
30
use function getcwd;
31
use function is_object;
32
use KevinGH\Box\Compactor\PhpScoper;
33
use KevinGH\Box\Compactor\Placeholder;
34
use KevinGH\Box\Composer\ComposerOrchestrator;
35
use function KevinGH\Box\FileSystem\dump_file;
36
use function KevinGH\Box\FileSystem\file_contents;
37
use function KevinGH\Box\FileSystem\make_tmp_dir;
38
use function KevinGH\Box\FileSystem\mkdir;
39
use function KevinGH\Box\FileSystem\remove;
40
use KevinGH\Box\PhpScoper\NullScoper;
41
use KevinGH\Box\PhpScoper\WhitelistManipulator;
42
use function openssl_pkey_export;
43
use function openssl_pkey_get_details;
44
use function openssl_pkey_get_private;
45
use Phar;
46
use RecursiveDirectoryIterator;
47
use RuntimeException;
48
use SplFileInfo;
49
use function sprintf;
50
use Symfony\Component\Console\Input\StringInput;
51
use Symfony\Component\Console\Output\NullOutput;
52
use Symfony\Component\Console\Style\SymfonyStyle;
53
54
/**
55
 * Box is a utility class to generate a PHAR.
56
 *
57
 * @private
58
 */
59
final class Box implements Countable
60
{
61
    /** @var string The path to the PHAR file */
62
    private $file;
63
64
    /** @var Phar The PHAR instance */
65
    private $phar;
66
67
    private $compactors;
68
    private $placeholderCompactor;
69
    private $mapFile;
70
    private $scoper;
71
    private $buffering = false;
72
    private $bufferedFiles = [];
73
74
    private function __construct(Phar $phar, string $file)
75
    {
76
        $this->phar = $phar;
77
        $this->file = $file;
78
79
        $this->compactors = new Compactors();
80
        $this->placeholderCompactor = new Placeholder([]);
81
        $this->mapFile = new MapFile(getcwd(), []);
82
        $this->scoper = new NullScoper();
83
    }
84
85
    /**
86
     * Creates a new PHAR and Box instance.
87
     *
88
     * @param string $file  The PHAR file name
89
     * @param int    $flags Flags to pass to the Phar parent class RecursiveDirectoryIterator
90
     * @param string $alias Alias with which the Phar archive should be referred to in calls to stream functionality
91
     *
92
     * @return Box
93
     *
94
     * @see RecursiveDirectoryIterator
95
     */
96
    public static function create(string $file, ?int $flags = null, ?string $alias = null): self
97
    {
98
        // Ensure the parent directory of the PHAR file exists as `new \Phar()` does not create it and would fail
99
        // otherwise.
100
        mkdir(dirname($file));
101
102
        return new self(new Phar($file, (int) $flags, $alias), $file);
103
    }
104
105
    public function startBuffering(): void
106
    {
107
        Assertion::false($this->buffering, 'The buffering must be ended before starting it again');
108
109
        $this->buffering = true;
110
111
        $this->phar->startBuffering();
112
    }
113
114
    public function endBuffering(bool $dumpAutoload, bool $excludeDevFiles, SymfonyStyle $io = null): void
115
    {
116
        Assertion::true($this->buffering, 'The buffering must be started before ending it');
117
118
        if (null === $io) {
119
            $io = new SymfonyStyle(
120
                new StringInput(''),
121
                new NullOutput()
122
            );
123
        }
124
125
        $cwd = getcwd();
126
127
        $tmp = make_tmp_dir('box', self::class);
128
        chdir($tmp);
129
130
        if ([] === $this->bufferedFiles) {
131
            $this->bufferedFiles = [
132
                '.box_empty' => 'A PHAR cannot be empty so Box adds this file to ensure the PHAR is created still.',
133
            ];
134
        }
135
136
        try {
137
            foreach ($this->bufferedFiles as $file => $contents) {
138
                dump_file($file, $contents);
139
            }
140
141
            if ($dumpAutoload) {
142
                // Dump autoload without dev dependencies
143
                ComposerOrchestrator::dumpAutoload(
144
                    $this->scoper->getWhitelist(),
145
                    $this->scoper->getPrefix(),
146
                    $excludeDevFiles,
147
                    $io
148
                );
149
            }
150
151
            chdir($cwd);
152
153
            $this->phar->buildFromDirectory($tmp);
154
        } finally {
155
            remove($tmp);
156
        }
157
158
        $this->buffering = false;
159
160
        $this->phar->stopBuffering();
161
    }
162
163
    public function removeComposerArtefacts(string $vendorDir): void
164
    {
165
        Assertion::false($this->buffering, 'The buffering must have ended before removing the Composer artefacts');
166
167
        $composerFiles = [
168
            'composer.json',
169
            'composer.lock',
170
            $vendorDir.'/composer/installed.json',
171
        ];
172
173
        $this->phar->startBuffering();
174
175
        foreach ($composerFiles as $composerFile) {
176
            $localComposerFile = ($this->mapFile)($composerFile);
177
178
            if (file_exists('phar://'.$this->phar->getPath().'/'.$localComposerFile)) {
179
                $this->phar->delete($localComposerFile);
180
            }
181
        }
182
183
        $this->phar->stopBuffering();
184
    }
185
186
    /**
187
     * @return null|string The required extension to execute the PHAR now that it is compressed
188
     */
189
    public function compress(int $compressionAlgorithm): ?string
190
    {
191
        Assertion::false($this->buffering, 'Cannot compress files while buffering.');
192
        Assertion::inArray($compressionAlgorithm, get_phar_compression_algorithms());
193
194
        $extensionRequired = get_phar_compression_algorithm_extension($compressionAlgorithm);
195
196
        if (null !== $extensionRequired && false === extension_loaded($extensionRequired)) {
197
            throw new RuntimeException(
198
                sprintf(
199
                    'Cannot compress the PHAR with the compression algorithm "%s": the extension "%s" is required but appear to not '
200
                    .'be loaded',
201
                    array_flip(get_phar_compression_algorithms())[$compressionAlgorithm],
202
                    $extensionRequired
203
                )
204
            );
205
        }
206
207
        try {
208
            if (Phar::NONE === $compressionAlgorithm) {
209
                $this->getPhar()->decompressFiles();
210
            } else {
211
                $this->phar->compressFiles($compressionAlgorithm);
212
            }
213
        } catch (BadMethodCallException $exception) {
214
            $exceptionMessage = 'unable to create temporary file' !== $exception->getMessage()
215
                ? 'Could not compress the PHAR: '.$exception->getMessage()
216
                : sprintf(
217
                    'Could not compress the PHAR: the compression requires too many file descriptors to be opened (%s). Check '
218
                    .'your system limits or install the posix extension to allow Box to automatically configure it during the compression',
219
                    $this->phar->count()
220
                )
221
            ;
222
223
            throw new RuntimeException($exceptionMessage, $exception->getCode(), $exception);
224
        }
225
226
        return $extensionRequired;
227
    }
228
229
    /**
230
     * @param Compactor[] $compactors
231
     */
232
    public function registerCompactors(array $compactors): void
233
    {
234
        Assertion::allIsInstanceOf($compactors, Compactor::class);
235
236
        foreach ($compactors as $index => $compactor) {
237
            if ($compactor instanceof PhpScoper) {
238
                $this->scoper = $compactor->getScoper();
239
240
                continue;
241
            }
242
243
            if ($compactor instanceof Placeholder) {
244
                unset($compactors[$index]);
245
            }
246
        }
247
248
        array_unshift($compactors, $this->placeholderCompactor);
249
250
        $this->compactors = new Compactors(...$compactors);
251
    }
252
253
    /**
254
     * @param scalar[] $placeholders
255
     */
256
    public function registerPlaceholders(array $placeholders): void
257
    {
258
        $message = 'Expected value "%s" to be a scalar or stringable object.';
259
260
        foreach ($placeholders as $i => $placeholder) {
261
            if (is_object($placeholder)) {
262
                Assertion::methodExists('__toString', $placeholder, $message);
263
264
                $placeholders[$i] = (string) $placeholder;
265
266
                break;
267
            }
268
269
            Assertion::scalar($placeholder, $message);
270
        }
271
272
        $this->placeholderCompactor = new Placeholder($placeholders);
273
        $this->registerCompactors($this->compactors->toArray());
274
    }
275
276
    public function registerFileMapping(MapFile $fileMapper): void
277
    {
278
        $this->mapFile = $fileMapper;
279
    }
280
281
    public function registerStub(string $file): void
282
    {
283
        $contents = $this->placeholderCompactor->compact(
284
            $file,
285
            file_contents($file)
286
        );
287
288
        $this->phar->setStub($contents);
289
    }
290
291
    /**
292
     * @param SplFileInfo[]|string[] $files
293
     */
294
    public function addFiles(array $files, bool $binary): void
295
    {
296
        Assertion::true($this->buffering, 'Cannot add files if the buffering has not started.');
297
298
        $files = array_map(
299
            static function ($file): string {
300
                // Convert files to string as SplFileInfo is not serializable
301
                return (string) $file;
302
            },
303
            $files
304
        );
305
306
        if ($binary) {
307
            foreach ($files as $file) {
308
                $this->addFile($file, null, $binary);
309
            }
310
311
            return;
312
        }
313
314
        foreach ($this->processContents($files) as [$file, $contents]) {
315
            $this->bufferedFiles[$file] = $contents;
316
        }
317
    }
318
319
    /**
320
     * Adds the a file to the PHAR. The contents will first be compacted and have its placeholders
321
     * replaced.
322
     *
323
     * @param null|string $contents If null the content of the file will be used
324
     * @param bool        $binary   When true means the file content shouldn't be processed
325
     *
326
     * @return string File local path
327
     */
328
    public function addFile(string $file, ?string $contents = null, bool $binary = false): string
329
    {
330
        Assertion::true($this->buffering, 'Cannot add files if the buffering has not started.');
331
332
        if (null === $contents) {
333
            $contents = file_contents($file);
334
        }
335
336
        $local = ($this->mapFile)($file);
337
338
        $this->bufferedFiles[$local] = $binary ? $contents : $this->compactors->compactContents($local, $contents);
339
340
        return $local;
341
    }
342
343
    public function getPhar(): Phar
344
    {
345
        return $this->phar;
346
    }
347
348
    /**
349
     * Signs the PHAR using a private key file.
350
     *
351
     * @param string      $file     the private key file name
352
     * @param null|string $password the private key password
353
     */
354
    public function signUsingFile(string $file, ?string $password = null): void
355
    {
356
        $this->sign(file_contents($file), $password);
357
    }
358
359
    /**
360
     * Signs the PHAR using a private key.
361
     *
362
     * @param string      $key      The private key
363
     * @param null|string $password The private key password
364
     */
365
    public function sign(string $key, ?string $password): void
366
    {
367
        $pubKey = $this->file.'.pubkey';
368
369
        Assertion::writeable(dirname($pubKey));
370
        Assertion::extensionLoaded('openssl');
371
372
        if (file_exists($pubKey)) {
373
            Assertion::file(
374
                $pubKey,
375
                'Cannot create public key: "%s" already exists and is not a file.'
376
            );
377
        }
378
379
        $resource = openssl_pkey_get_private($key, (string) $password);
380
381
        Assertion::isResource($resource, 'Could not retrieve the private key, check that the password is correct.');
382
383
        openssl_pkey_export($resource, $private);
384
385
        $details = openssl_pkey_get_details($resource);
386
387
        $this->phar->setSignatureAlgorithm(Phar::OPENSSL, $private);
388
389
        dump_file($pubKey, $details['key']);
390
    }
391
392
    /**
393
     * @param string[] $files
394
     *
395
     * @return array array of tuples where the first element is the local file path (path inside the PHAR) and the
396
     *               second element is the processed contents
397
     */
398
    private function processContents(array $files): array
399
    {
400
        $mapFile = $this->mapFile;
401
        $compactors = $this->compactors;
402
        $bootstrap = $GLOBALS['_BOX_BOOTSTRAP'] ?? static function (): void {};
403
        $cwd = getcwd();
404
405
        $processFile = static function (string $file) use ($cwd, $mapFile, $compactors, $bootstrap): array {
406
            chdir($cwd);
407
            $bootstrap();
408
409
            $contents = file_contents($file);
410
411
            $local = $mapFile($file);
412
413
            $processedContents = $compactors->compactContents($local, $contents);
414
415
            return [$local, $processedContents, $compactors->getScoperWhitelist()];
416
        };
417
418
        if ($this->scoper instanceof NullScoper || false === is_parallel_processing_enabled()) {
0 ignored issues
show
introduced by
$this->scoper is always a sub-type of KevinGH\Box\PhpScoper\NullScoper.
Loading history...
419
            return array_map($processFile, $files);
420
        }
421
422
        // In the case of parallel processing, an issue is caused due to the statefulness nature of the PhpScoper
423
        // whitelist.
424
        //
425
        // Indeed the PhpScoper Whitelist stores the records of whitelisted classes and functions. If nothing is done,
426
        // then the whitelisted retrieve in the end will here will be "blank" since the updated whitelists are the ones
427
        // from the workers used for the parallel processing.
428
        //
429
        // In order to avoid that, the whitelists will be returned as a result as well in order to be able to merge
430
        // all the whitelists into one.
431
        //
432
        // This process is allowed thanks to the nature of the state of the whitelists: having redundant classes or
433
        // functions registered can easily be deal with so merging all those different states is actually
434
        // straightforward.
435
        $tuples = wait(parallelMap($files, $processFile));
436
437
        if ([] === $tuples) {
438
            return [];
439
        }
440
441
        $filesWithContents = [];
442
        $whitelists = [];
443
444
        foreach ($tuples as [$local, $processedContents, $whitelist]) {
445
            $filesWithContents[] = [$local, $processedContents];
446
            $whitelists[] = $whitelist;
447
        }
448
449
        $this->compactors->registerWhitelist(
450
            WhitelistManipulator::mergeWhitelists(
451
                ...array_filter($whitelists)
452
            )
453
        );
454
455
        return $filesWithContents;
456
    }
457
458
    /**
459
     * {@inheritdoc}
460
     */
461
    public function count(): int
462
    {
463
        Assertion::false($this->buffering, 'Cannot count the number of files in the PHAR when buffering');
464
465
        return $this->phar->count();
466
    }
467
}
468