Passed
Pull Request — master (#235)
by Théo
02:49
created

Box::create()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 7
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 2
nc 1
nop 3
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 Assert\Assertion;
18
use BadMethodCallException;
19
use Closure;
20
use Countable;
21
use KevinGH\Box\Compactor\PhpScoper;
22
use KevinGH\Box\Composer\ComposerOrchestrator;
23
use KevinGH\Box\PhpScoper\NullScoper;
24
use KevinGH\Box\PhpScoper\Scoper;
25
use Phar;
26
use RecursiveDirectoryIterator;
27
use RuntimeException;
28
use SplFileInfo;
29
use function Amp\ParallelFunctions\parallelMap;
30
use function Amp\Promise\wait;
31
use function array_flip;
32
use function array_map;
33
use function chdir;
34
use function extension_loaded;
35
use function file_exists;
36
use function getcwd;
37
use function KevinGH\Box\FileSystem\dump_file;
38
use function KevinGH\Box\FileSystem\file_contents;
39
use function KevinGH\Box\FileSystem\make_path_relative;
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 function sprintf;
44
45
/**
46
 * Box is a utility class to generate a PHAR.
47
 *
48
 * @private
49
 */
50
final class Box implements Countable
51
{
52
    public const DEBUG_DIR = '.box_dump';
53
54
    /**
55
     * @var Compactor[]
56
     */
57
    private $compactors = [];
58
59
    /**
60
     * @var string The path to the PHAR file
61
     */
62
    private $file;
63
64
    /**
65
     * @var Phar The PHAR instance
66
     */
67
    private $phar;
68
69
    /**
70
     * @var scalar[] The placeholders with their values
71
     */
72
    private $placeholders = [];
73
74
    /**
75
     * @var string
76
     */
77
    private $basePath;
78
79
    /**
80
     * @var Closure|MapFile
81
     */
82
    private $mapFile;
83
84
    /**
85
     * @var Scoper
86
     */
87
    private $scoper;
88
89
    private $buffering = false;
90
91
    private $bufferedFiles = [];
92
93
    private function __construct(Phar $phar, string $file)
94
    {
95
        $this->phar = $phar;
96
        $this->file = $file;
97
98
        $this->basePath = getcwd();
99
        $this->mapFile = function (): void { };
100
        $this->scoper = new NullScoper();
101
    }
102
103
    /**
104
     * Creates a new PHAR and Box instance.
105
     *
106
     * @param string $file  The PHAR file name
107
     * @param int    $flags Flags to pass to the Phar parent class RecursiveDirectoryIterator
108
     * @param string $alias Alias with which the Phar archive should be referred to in calls to stream functionality
109
     *
110
     * @return Box
111
     *
112
     * @see RecursiveDirectoryIterator
113
     */
114
    public static function create(string $file, int $flags = null, string $alias = null): self
115
    {
116
        // Ensure the parent directory of the PHAR file exists as `new \Phar()` does not create it and would fail
117
        // otherwise.
118
        mkdir(dirname($file));
119
120
        return new self(new Phar($file, (int) $flags, $alias), $file);
121
    }
122
123
    public function startBuffering(): void
124
    {
125
        Assertion::false($this->buffering, 'The buffering must be ended before starting it again');
126
127
        $this->buffering = true;
128
129
        $this->phar->startBuffering();
130
    }
131
132
    public function endBuffering(bool $dumpAutoload): void
133
    {
134
        Assertion::true($this->buffering, 'The buffering must be started before ending it');
135
136
        $cwd = getcwd();
137
138
        $tmp = make_tmp_dir('box', __CLASS__);
139
        chdir($tmp);
140
141
        if ([] === $this->bufferedFiles) {
142
            $this->bufferedFiles = [
143
                '.box_empty' => 'A PHAR cannot be empty so Box adds this file to ensure the PHAR is created still.',
144
            ];
145
        }
146
147
        try {
148
            foreach ($this->bufferedFiles as $file => $contents) {
149
                dump_file($file, $contents);
150
            }
151
152
            if ($dumpAutoload) {
153
                // Dump autoload without dev dependencies
154
                ComposerOrchestrator::dumpAutoload($this->scoper->getWhitelist(), $this->scoper->getPrefix());
155
            }
156
157
            chdir($cwd);
158
159
            $this->phar->buildFromDirectory($tmp);
160
        } finally {
161
            remove($tmp);
162
        }
163
164
        $this->buffering = false;
165
166
        $this->phar->stopBuffering();
167
    }
168
169
    public function removeComposerArtefacts(string $vendorDir): void
170
    {
171
        Assertion::false($this->buffering, 'The buffering must have ended before removing the Composer artefacts');
172
173
        $composerFiles = [
174
            'composer.json',
175
            'composer.lock',
176
            $vendorDir.'/composer/installed.json',
177
        ];
178
179
        $this->phar->startBuffering();
180
181
        foreach ($composerFiles as $composerFile) {
182
            // TODO: the file map could return the unchanged path when no mapping is found...
183
            $localComposerFile = ($this->mapFile)($composerFile);
184
185
            if (null === $localComposerFile) {
186
                $localComposerFile = $composerFile;
187
            }
188
189
            if (file_exists('phar://'.$this->phar->getPath().'/'.$localComposerFile)) {
190
                $this->phar->delete($localComposerFile);
191
            }
192
        }
193
194
        $this->phar->stopBuffering();
195
    }
196
197
    /**
198
     * @return null|string The required extension to execute the PHAR now that it is compressed
199
     */
200
    public function compress(int $compressionAlgorithm): ?string
201
    {
202
        Assertion::false($this->buffering, 'Cannot compress files while buffering.');
203
        Assertion::inArray($compressionAlgorithm, get_phar_compression_algorithms());
204
205
        $extensionRequired = get_phar_compression_algorithm_extension($compressionAlgorithm);
206
207
        if (null !== $extensionRequired && false === extension_loaded($extensionRequired)) {
208
            throw new RuntimeException(
209
                sprintf(
210
                    'Cannot compress the PHAR with the compression algorithm "%s": the extension "%s" is required but appear to not '
211
                    .'be loaded',
212
                    array_flip(get_phar_compression_algorithms())[$compressionAlgorithm],
213
                    $extensionRequired
214
                )
215
            );
216
        }
217
218
        try {
219
            if (Phar::NONE === $compressionAlgorithm) {
220
                $this->getPhar()->decompressFiles();
221
            } else {
222
                $this->phar->compressFiles($compressionAlgorithm);
223
            }
224
        } catch (BadMethodCallException $exception) {
225
            $exceptionMessage = 'unable to create temporary file' !== $exception->getMessage()
226
                ? 'Could not compress the PHAR: '.$exception->getMessage()
227
                : sprintf(
228
                    'Could not compress the PHAR: the compression requires too many file descriptors to be opened (%s). Check '
229
                    .'your system limits or install the posix extension to allow Box to automatically configure it during the compression',
230
                    $this->phar->count()
231
                )
232
            ;
233
234
            throw new RuntimeException($exceptionMessage, $exception->getCode(), $exception);
235
        }
236
237
        return $extensionRequired;
238
    }
239
240
    /**
241
     * @param Compactor[] $compactors
242
     */
243
    public function registerCompactors(array $compactors): void
244
    {
245
        Assertion::allIsInstanceOf($compactors, Compactor::class);
246
247
        $this->compactors = $compactors;
248
249
        foreach ($this->compactors as $compactor) {
250
            if ($compactor instanceof PhpScoper) {
251
                $this->scoper = $compactor->getScoper();
252
253
                break;
254
            }
255
        }
256
    }
257
258
    /**
259
     * @param scalar[] $placeholders
260
     */
261
    public function registerPlaceholders(array $placeholders): void
262
    {
263
        $message = 'Expected value "%s" to be a scalar or stringable object.';
264
265
        foreach ($placeholders as $i => $placeholder) {
266
            if (is_object($placeholder)) {
267
                Assertion::methodExists('__toString', $placeholder, $message);
268
269
                $placeholders[$i] = (string) $placeholder;
270
271
                break;
272
            }
273
274
            Assertion::scalar($placeholder, $message);
275
        }
276
277
        $this->placeholders = $placeholders;
278
    }
279
280
    public function registerFileMapping(string $basePath, MapFile $fileMapper): void
281
    {
282
        $this->basePath = $basePath;
283
        $this->mapFile = $fileMapper;
284
    }
285
286
    public function registerStub(string $file): void
287
    {
288
        $contents = self::replacePlaceholders(
289
            $this->placeholders,
290
            file_contents($file)
291
        );
292
293
        $this->phar->setStub($contents);
294
    }
295
296
    /**
297
     * @param SplFileInfo[]|string[] $files
298
     */
299
    public function addFiles(array $files, bool $binary): void
300
    {
301
        Assertion::true($this->buffering, 'Cannot add files if the buffering has not started.');
302
303
        $files = array_map(
304
            function ($file): string {
305
                // Convert files to string as SplFileInfo is not serializable
306
                return (string) $file;
307
            },
308
            $files
309
        );
310
311
        if ($binary) {
312
            foreach ($files as $file) {
313
                $this->addFile($file, null, $binary);
314
            }
315
316
            return;
317
        }
318
319
        $filesWithContents = $this->processContents($files);
320
321
        foreach ($filesWithContents as $fileWithContents) {
322
            [$file, $contents] = $fileWithContents;
323
324
            $this->bufferedFiles[$file] = $contents;
325
        }
326
    }
327
328
    /**
329
     * Adds the a file to the PHAR. The contents will first be compacted and have its placeholders
330
     * replaced.
331
     *
332
     * @param string      $file
333
     * @param null|string $contents If null the content of the file will be used
334
     * @param bool        $binary   When true means the file content shouldn't be processed
335
     *
336
     * @return string File local path
337
     */
338
    public function addFile(string $file, string $contents = null, bool $binary = false): string
339
    {
340
        Assertion::true($this->buffering, 'Cannot add files if the buffering has not started.');
341
342
        if (null === $contents) {
343
            $contents = file_contents($file);
344
        }
345
346
        $relativePath = make_path_relative($file, $this->basePath);
347
        $local = ($this->mapFile)($relativePath);
348
349
        if (null === $local) {
350
            $local = $relativePath;
351
        }
352
353
        if ($binary) {
354
            $this->bufferedFiles[$local] = $contents;
355
        } else {
356
            $processedContents = self::compactContents(
357
                $this->compactors,
358
                $local,
359
                self::replacePlaceholders($this->placeholders, $contents)
360
            );
361
362
            $this->bufferedFiles[$local] = $processedContents;
363
        }
364
365
        return $local;
366
    }
367
368
    public function getPhar(): Phar
369
    {
370
        return $this->phar;
371
    }
372
373
    /**
374
     * Signs the PHAR using a private key file.
375
     *
376
     * @param string $file     the private key file name
377
     * @param string $password the private key password
378
     */
379
    public function signUsingFile(string $file, string $password = null): void
380
    {
381
        $this->sign(file_contents($file), $password);
382
    }
383
384
    /**
385
     * Signs the PHAR using a private key.
386
     *
387
     * @param string $key      The private key
388
     * @param string $password The private key password
389
     */
390
    public function sign(string $key, ?string $password): void
391
    {
392
        $pubKey = $this->file.'.pubkey';
393
394
        Assertion::writeable(dirname($pubKey));
395
        Assertion::extensionLoaded('openssl');
396
397
        if (file_exists($pubKey)) {
398
            Assertion::file(
399
                $pubKey,
400
                'Cannot create public key: "%s" already exists and is not a file.'
401
            );
402
        }
403
404
        $resource = openssl_pkey_get_private($key, (string) $password);
405
406
        openssl_pkey_export($resource, $private);
407
408
        $details = openssl_pkey_get_details($resource);
0 ignored issues
show
Bug introduced by
It seems like $resource can also be of type false; however, parameter $key of openssl_pkey_get_details() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

408
        $details = openssl_pkey_get_details(/** @scrutinizer ignore-type */ $resource);
Loading history...
409
410
        $this->phar->setSignatureAlgorithm(Phar::OPENSSL, $private);
411
412
        dump_file($pubKey, $details['key']);
413
    }
414
415
    /**
416
     * @param string[] $files
417
     *
418
     * @return array array of tuples where the first element is the local file path (path inside the PHAR) and the
419
     *               second element is the processed contents
420
     */
421
    private function processContents(array $files): array
422
    {
423
        $basePath = $this->basePath;
424
        $mapFile = $this->mapFile;
425
        $placeholders = $this->placeholders;
426
        $compactors = $this->compactors;
427
        $bootstrap = $GLOBALS['_BOX_BOOTSTRAP'] ?? function (): void {};
428
        $cwd = getcwd();
429
430
        $processFile = function (string $file) use ($cwd, $basePath, $mapFile, $placeholders, $compactors, $bootstrap): array {
431
            chdir($cwd);
432
            $bootstrap();
433
434
            $contents = file_contents($file);
435
436
            $relativePath = make_path_relative($file, $basePath);
437
            $local = $mapFile($relativePath);
438
439
            if (null === $local) {
440
                $local = $relativePath;
441
            }
442
443
            $processedContents = self::compactContents(
444
                $compactors,
445
                $local,
446
                self::replacePlaceholders($placeholders, $contents)
447
            );
448
449
            return [$local, $processedContents];
450
        };
451
452
        return is_parallel_processing_enabled() && false === ($this->scoper instanceof NullScoper)
453
            ? wait(parallelMap($files, $processFile))
454
            : array_map($processFile, $files)
455
        ;
456
    }
457
458
    /**
459
     * Replaces the placeholders with their values.
460
     *
461
     * @param array  $placeholders
462
     * @param string $contents     the contents
463
     *
464
     * @return string the replaced contents
465
     */
466
    private static function replacePlaceholders(array $placeholders, string $contents): string
467
    {
468
        return str_replace(
469
            array_keys($placeholders),
470
            array_values($placeholders),
471
            $contents
472
        );
473
    }
474
475
    private static function compactContents(array $compactors, string $file, string $contents): string
476
    {
477
        return array_reduce(
478
            $compactors,
479
            function (string $contents, Compactor $compactor) use ($file): string {
480
                return $compactor->compact($file, $contents);
481
            },
482
            $contents
483
        );
484
    }
485
486
    /**
487
     * {@inheritdoc}
488
     */
489
    public function count(): int
490
    {
491
        Assertion::false($this->buffering, 'Cannot count the number of files in the PHAR when buffering');
492
493
        return $this->phar->count();
494
    }
495
}
496