Passed
Pull Request — master (#411)
by Théo
01:58
created

Box::endBuffering()   A

Complexity

Conditions 4
Paths 30

Size

Total Lines 37
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 20
dl 0
loc 37
rs 9.6
c 0
b 0
f 0
cc 4
nc 30
nop 1
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 Amp\MultiReasonException;
18
use function Amp\ParallelFunctions\parallelMap;
19
use function Amp\Promise\wait;
20
use function array_filter;
21
use function array_flip;
22
use function array_map;
23
use function array_unshift;
24
use Assert\Assertion;
25
use BadMethodCallException;
26
use function chdir;
27
use Closure;
28
use Countable;
29
use function dirname;
30
use function extension_loaded;
31
use function file_exists;
32
use function getcwd;
33
use function is_object;
34
use KevinGH\Box\Compactor\Compactor;
35
use KevinGH\Box\Compactor\Compactors;
36
use KevinGH\Box\Compactor\PhpScoper;
37
use KevinGH\Box\Compactor\Placeholder;
38
use function KevinGH\Box\FileSystem\dump_file;
39
use function KevinGH\Box\FileSystem\file_contents;
40
use function KevinGH\Box\FileSystem\make_tmp_dir;
41
use function KevinGH\Box\FileSystem\mkdir;
42
use function KevinGH\Box\FileSystem\remove;
43
use KevinGH\Box\PhpScoper\NullScoper;
44
use KevinGH\Box\PhpScoper\WhitelistManipulator;
45
use function openssl_pkey_export;
46
use function openssl_pkey_get_details;
47
use function openssl_pkey_get_private;
48
use Phar;
49
use RecursiveDirectoryIterator;
50
use RuntimeException;
51
use SplFileInfo;
52
use function sprintf;
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(?Closure $dumpAutoload): void
115
    {
116
        Assertion::true($this->buffering, 'The buffering must be started before ending it');
117
118
        $cwd = getcwd();
119
120
        $tmp = make_tmp_dir('box', self::class);
121
        chdir($tmp);
122
123
        if ([] === $this->bufferedFiles) {
124
            $this->bufferedFiles = [
125
                '.box_empty' => 'A PHAR cannot be empty so Box adds this file to ensure the PHAR is created still.',
126
            ];
127
        }
128
129
        try {
130
            foreach ($this->bufferedFiles as $file => $contents) {
131
                dump_file($file, $contents);
132
            }
133
134
            if (null !== $dumpAutoload) {
135
                $dumpAutoload(
136
                    $this->scoper->getWhitelist(),
137
                    $this->scoper->getPrefix()
138
                );
139
            }
140
141
            chdir($cwd);
142
143
            $this->phar->buildFromDirectory($tmp);
144
        } finally {
145
            remove($tmp);
146
        }
147
148
        $this->buffering = false;
149
150
        $this->phar->stopBuffering();
151
    }
152
153
    public function removeComposerArtefacts(string $vendorDir): void
154
    {
155
        Assertion::false($this->buffering, 'The buffering must have ended before removing the Composer artefacts');
156
157
        $composerFiles = [
158
            'composer.json',
159
            'composer.lock',
160
            $vendorDir.'/composer/installed.json',
161
        ];
162
163
        $this->phar->startBuffering();
164
165
        foreach ($composerFiles as $composerFile) {
166
            $localComposerFile = ($this->mapFile)($composerFile);
167
168
            if (file_exists('phar://'.$this->phar->getPath().'/'.$localComposerFile)) {
169
                $this->phar->delete($localComposerFile);
170
            }
171
        }
172
173
        $this->phar->stopBuffering();
174
    }
175
176
    /**
177
     * @return null|string The required extension to execute the PHAR now that it is compressed
178
     */
179
    public function compress(int $compressionAlgorithm): ?string
180
    {
181
        Assertion::false($this->buffering, 'Cannot compress files while buffering.');
182
        Assertion::inArray($compressionAlgorithm, get_phar_compression_algorithms());
183
184
        $extensionRequired = get_phar_compression_algorithm_extension($compressionAlgorithm);
185
186
        if (null !== $extensionRequired && false === extension_loaded($extensionRequired)) {
187
            throw new RuntimeException(
188
                sprintf(
189
                    'Cannot compress the PHAR with the compression algorithm "%s": the extension "%s" is required but appear to not '
190
                    .'be loaded',
191
                    array_flip(get_phar_compression_algorithms())[$compressionAlgorithm],
192
                    $extensionRequired
193
                )
194
            );
195
        }
196
197
        try {
198
            if (Phar::NONE === $compressionAlgorithm) {
199
                $this->getPhar()->decompressFiles();
200
            } else {
201
                $this->phar->compressFiles($compressionAlgorithm);
202
            }
203
        } catch (BadMethodCallException $exception) {
204
            $exceptionMessage = 'unable to create temporary file' !== $exception->getMessage()
205
                ? 'Could not compress the PHAR: '.$exception->getMessage()
206
                : sprintf(
207
                    'Could not compress the PHAR: the compression requires too many file descriptors to be opened (%s). Check '
208
                    .'your system limits or install the posix extension to allow Box to automatically configure it during the compression',
209
                    $this->phar->count()
210
                )
211
            ;
212
213
            throw new RuntimeException($exceptionMessage, $exception->getCode(), $exception);
214
        }
215
216
        return $extensionRequired;
217
    }
218
219
    /**
220
     * @param Compactor[] $compactors
221
     */
222
    public function registerCompactors(array $compactors): void
223
    {
224
        Assertion::allIsInstanceOf($compactors, Compactor::class);
225
226
        foreach ($compactors as $index => $compactor) {
227
            if ($compactor instanceof PhpScoper) {
228
                $this->scoper = $compactor->getScoper();
229
230
                continue;
231
            }
232
233
            if ($compactor instanceof Placeholder) {
234
                unset($compactors[$index]);
235
            }
236
        }
237
238
        array_unshift($compactors, $this->placeholderCompactor);
239
240
        $this->compactors = new Compactors(...$compactors);
241
    }
242
243
    /**
244
     * @param scalar[] $placeholders
245
     */
246
    public function registerPlaceholders(array $placeholders): void
247
    {
248
        $message = 'Expected value "%s" to be a scalar or stringable object.';
249
250
        foreach ($placeholders as $i => $placeholder) {
251
            if (is_object($placeholder)) {
252
                Assertion::methodExists('__toString', $placeholder, $message);
253
254
                $placeholders[$i] = (string) $placeholder;
255
256
                break;
257
            }
258
259
            Assertion::scalar($placeholder, $message);
260
        }
261
262
        $this->placeholderCompactor = new Placeholder($placeholders);
263
        $this->registerCompactors($this->compactors->toArray());
264
    }
265
266
    public function registerFileMapping(MapFile $fileMapper): void
267
    {
268
        $this->mapFile = $fileMapper;
269
    }
270
271
    public function registerStub(string $file): void
272
    {
273
        $contents = $this->placeholderCompactor->compact(
274
            $file,
275
            file_contents($file)
276
        );
277
278
        $this->phar->setStub($contents);
279
    }
280
281
    /**
282
     * @param SplFileInfo[]|string[] $files
283
     *
284
     * @throws MultiReasonException
285
     */
286
    public function addFiles(array $files, bool $binary): void
287
    {
288
        Assertion::true($this->buffering, 'Cannot add files if the buffering has not started.');
289
290
        $files = array_map('strval', $files);
291
292
        if ($binary) {
293
            foreach ($files as $file) {
294
                $this->addFile($file, null, $binary);
295
            }
296
297
            return;
298
        }
299
300
        foreach ($this->processContents($files) as [$file, $contents]) {
301
            $this->bufferedFiles[$file] = $contents;
302
        }
303
    }
304
305
    /**
306
     * Adds the a file to the PHAR. The contents will first be compacted and have its placeholders
307
     * replaced.
308
     *
309
     * @param null|string $contents If null the content of the file will be used
310
     * @param bool        $binary   When true means the file content shouldn't be processed
311
     *
312
     * @return string File local path
313
     */
314
    public function addFile(string $file, ?string $contents = null, bool $binary = false): string
315
    {
316
        Assertion::true($this->buffering, 'Cannot add files if the buffering has not started.');
317
318
        if (null === $contents) {
319
            $contents = file_contents($file);
320
        }
321
322
        $local = ($this->mapFile)($file);
323
324
        $this->bufferedFiles[$local] = $binary ? $contents : $this->compactors->compactContents($local, $contents);
325
326
        return $local;
327
    }
328
329
    public function getPhar(): Phar
330
    {
331
        return $this->phar;
332
    }
333
334
    /**
335
     * Signs the PHAR using a private key file.
336
     *
337
     * @param string      $file     the private key file name
338
     * @param null|string $password the private key password
339
     */
340
    public function signUsingFile(string $file, ?string $password = null): void
341
    {
342
        $this->sign(file_contents($file), $password);
343
    }
344
345
    /**
346
     * Signs the PHAR using a private key.
347
     *
348
     * @param string      $key      The private key
349
     * @param null|string $password The private key password
350
     */
351
    public function sign(string $key, ?string $password): void
352
    {
353
        $pubKey = $this->file.'.pubkey';
354
355
        Assertion::writeable(dirname($pubKey));
356
        Assertion::extensionLoaded('openssl');
357
358
        if (file_exists($pubKey)) {
359
            Assertion::file(
360
                $pubKey,
361
                'Cannot create public key: "%s" already exists and is not a file.'
362
            );
363
        }
364
365
        $resource = openssl_pkey_get_private($key, (string) $password);
366
367
        Assertion::isResource($resource, 'Could not retrieve the private key, check that the password is correct.');
368
369
        openssl_pkey_export($resource, $private);
370
371
        $details = openssl_pkey_get_details($resource);
372
373
        $this->phar->setSignatureAlgorithm(Phar::OPENSSL, $private);
374
375
        dump_file($pubKey, $details['key']);
376
    }
377
378
    /**
379
     * @param string[] $files
380
     *
381
     * @return array array of tuples where the first element is the local file path (path inside the PHAR) and the
382
     *               second element is the processed contents
383
     */
384
    private function processContents(array $files): array
385
    {
386
        $mapFile = $this->mapFile;
387
        $compactors = $this->compactors;
388
        $cwd = getcwd();
389
390
        $processFile = static function (string $file) use ($cwd, $mapFile, $compactors): array {
391
            chdir($cwd);
392
393
            // Keep the fully qualified call here since this function may be executed without the right autoloading
394
            // mechanism
395
            \KevinGH\Box\register_aliases();
396
            if (true === \KevinGH\Box\is_parallel_processing_enabled()) {
397
                \KevinGH\Box\register_error_handler();
398
            }
399
400
            $contents = file_contents($file);
401
402
            $local = $mapFile($file);
403
404
            $processedContents = $compactors->compactContents($local, $contents);
405
406
            return [$local, $processedContents, $compactors->getScoperWhitelist()];
407
        };
408
409
        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...
410
            return array_map($processFile, $files);
411
        }
412
413
        // In the case of parallel processing, an issue is caused due to the statefulness nature of the PhpScoper
414
        // whitelist.
415
        //
416
        // Indeed the PhpScoper Whitelist stores the records of whitelisted classes and functions. If nothing is done,
417
        // then the whitelisted retrieve in the end will here will be "blank" since the updated whitelists are the ones
418
        // from the workers used for the parallel processing.
419
        //
420
        // In order to avoid that, the whitelists will be returned as a result as well in order to be able to merge
421
        // all the whitelists into one.
422
        //
423
        // This process is allowed thanks to the nature of the state of the whitelists: having redundant classes or
424
        // functions registered can easily be deal with so merging all those different states is actually
425
        // straightforward.
426
        $tuples = wait(parallelMap($files, $processFile));
427
428
        if ([] === $tuples) {
429
            return [];
430
        }
431
432
        $filesWithContents = [];
433
        $whitelists = [];
434
435
        foreach ($tuples as [$local, $processedContents, $whitelist]) {
436
            $filesWithContents[] = [$local, $processedContents];
437
            $whitelists[] = $whitelist;
438
        }
439
440
        $this->compactors->registerWhitelist(
441
            WhitelistManipulator::mergeWhitelists(
442
                ...array_filter($whitelists)
443
            )
444
        );
445
446
        return $filesWithContents;
447
    }
448
449
    /**
450
     * {@inheritdoc}
451
     */
452
    public function count(): int
453
    {
454
        Assertion::false($this->buffering, 'Cannot count the number of files in the PHAR when buffering');
455
456
        return $this->phar->count();
457
    }
458
}
459