Passed
Push — master ( d697bf...e97b2a )
by Théo
02:42
created

Box::addFile()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 23
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 23
rs 9.0856
c 0
b 0
f 0
cc 3
eloc 13
nc 4
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 Countable;
20
use KevinGH\Box\Compactor\PhpScoper;
21
use KevinGH\Box\Composer\ComposerOrchestrator;
22
use KevinGH\Box\PhpScoper\NullScoper;
23
use Phar;
24
use RecursiveDirectoryIterator;
25
use RuntimeException;
26
use SplFileInfo;
27
use function Amp\ParallelFunctions\parallelMap;
28
use function Amp\Promise\wait;
29
use function array_flip;
30
use function array_map;
31
use function chdir;
32
use function extension_loaded;
33
use function file_exists;
34
use function getcwd;
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 function sprintf;
41
42
/**
43
 * Box is a utility class to generate a PHAR.
44
 *
45
 * @private
46
 */
47
final class Box implements Countable
48
{
49
    public const DEBUG_DIR = '.box_dump';
50
51
    /**
52
     * @var Compactor[]
53
     */
54
    private $compactors = [];
55
56
    /**
57
     * @var string The path to the PHAR file
58
     */
59
    private $file;
60
61
    /**
62
     * @var Phar The PHAR instance
63
     */
64
    private $phar;
65
66
    /**
67
     * @var scalar[] The placeholders with their values
68
     */
69
    private $placeholders = [];
70
71
    private $mapFile;
72
    private $scoper;
73
    private $buffering = false;
74
    private $bufferedFiles = [];
75
76
    private function __construct(Phar $phar, string $file)
77
    {
78
        $this->phar = $phar;
79
        $this->file = $file;
80
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): 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', __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 ($dumpAutoload) {
135
                // Dump autoload without dev dependencies
136
                ComposerOrchestrator::dumpAutoload($this->scoper->getWhitelist(), $this->scoper->getPrefix());
137
            }
138
139
            chdir($cwd);
140
141
            $this->phar->buildFromDirectory($tmp);
142
        } finally {
143
            remove($tmp);
144
        }
145
146
        $this->buffering = false;
147
148
        $this->phar->stopBuffering();
149
    }
150
151
    public function removeComposerArtefacts(string $vendorDir): void
152
    {
153
        Assertion::false($this->buffering, 'The buffering must have ended before removing the Composer artefacts');
154
155
        $composerFiles = [
156
            'composer.json',
157
            'composer.lock',
158
            $vendorDir.'/composer/installed.json',
159
        ];
160
161
        $this->phar->startBuffering();
162
163
        foreach ($composerFiles as $composerFile) {
164
            $localComposerFile = ($this->mapFile)($composerFile);
165
166
            if (file_exists('phar://'.$this->phar->getPath().'/'.$localComposerFile)) {
167
                $this->phar->delete($localComposerFile);
168
            }
169
        }
170
171
        $this->phar->stopBuffering();
172
    }
173
174
    /**
175
     * @return null|string The required extension to execute the PHAR now that it is compressed
176
     */
177
    public function compress(int $compressionAlgorithm): ?string
178
    {
179
        Assertion::false($this->buffering, 'Cannot compress files while buffering.');
180
        Assertion::inArray($compressionAlgorithm, get_phar_compression_algorithms());
181
182
        $extensionRequired = get_phar_compression_algorithm_extension($compressionAlgorithm);
183
184
        if (null !== $extensionRequired && false === extension_loaded($extensionRequired)) {
185
            throw new RuntimeException(
186
                sprintf(
187
                    'Cannot compress the PHAR with the compression algorithm "%s": the extension "%s" is required but appear to not '
188
                    .'be loaded',
189
                    array_flip(get_phar_compression_algorithms())[$compressionAlgorithm],
190
                    $extensionRequired
191
                )
192
            );
193
        }
194
195
        try {
196
            if (Phar::NONE === $compressionAlgorithm) {
197
                $this->getPhar()->decompressFiles();
198
            } else {
199
                $this->phar->compressFiles($compressionAlgorithm);
200
            }
201
        } catch (BadMethodCallException $exception) {
202
            $exceptionMessage = 'unable to create temporary file' !== $exception->getMessage()
203
                ? 'Could not compress the PHAR: '.$exception->getMessage()
204
                : sprintf(
205
                    'Could not compress the PHAR: the compression requires too many file descriptors to be opened (%s). Check '
206
                    .'your system limits or install the posix extension to allow Box to automatically configure it during the compression',
207
                    $this->phar->count()
208
                )
209
            ;
210
211
            throw new RuntimeException($exceptionMessage, $exception->getCode(), $exception);
212
        }
213
214
        return $extensionRequired;
215
    }
216
217
    /**
218
     * @param Compactor[] $compactors
219
     */
220
    public function registerCompactors(array $compactors): void
221
    {
222
        Assertion::allIsInstanceOf($compactors, Compactor::class);
223
224
        $this->compactors = $compactors;
225
226
        foreach ($this->compactors as $compactor) {
227
            if ($compactor instanceof PhpScoper) {
228
                $this->scoper = $compactor->getScoper();
229
230
                break;
231
            }
232
        }
233
    }
234
235
    /**
236
     * @param scalar[] $placeholders
237
     */
238
    public function registerPlaceholders(array $placeholders): void
239
    {
240
        $message = 'Expected value "%s" to be a scalar or stringable object.';
241
242
        foreach ($placeholders as $i => $placeholder) {
243
            if (is_object($placeholder)) {
244
                Assertion::methodExists('__toString', $placeholder, $message);
245
246
                $placeholders[$i] = (string) $placeholder;
247
248
                break;
249
            }
250
251
            Assertion::scalar($placeholder, $message);
252
        }
253
254
        $this->placeholders = $placeholders;
255
    }
256
257
    public function registerFileMapping(MapFile $fileMapper): void
258
    {
259
        $this->mapFile = $fileMapper;
260
    }
261
262
    public function registerStub(string $file): void
263
    {
264
        $contents = self::replacePlaceholders(
265
            $this->placeholders,
266
            file_contents($file)
267
        );
268
269
        $this->phar->setStub($contents);
270
    }
271
272
    /**
273
     * @param SplFileInfo[]|string[] $files
274
     */
275
    public function addFiles(array $files, bool $binary): void
276
    {
277
        Assertion::true($this->buffering, 'Cannot add files if the buffering has not started.');
278
279
        $files = array_map(
280
            function ($file): string {
281
                // Convert files to string as SplFileInfo is not serializable
282
                return (string) $file;
283
            },
284
            $files
285
        );
286
287
        if ($binary) {
288
            foreach ($files as $file) {
289
                $this->addFile($file, null, $binary);
290
            }
291
292
            return;
293
        }
294
295
        $filesWithContents = $this->processContents($files);
296
297
        foreach ($filesWithContents as $fileWithContents) {
298
            [$file, $contents] = $fileWithContents;
299
300
            $this->bufferedFiles[$file] = $contents;
301
        }
302
    }
303
304
    /**
305
     * Adds the a file to the PHAR. The contents will first be compacted and have its placeholders
306
     * replaced.
307
     *
308
     * @param string      $file
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
        if ($binary) {
325
            $this->bufferedFiles[$local] = $contents;
326
        } else {
327
            $processedContents = self::compactContents(
328
                $this->compactors,
329
                $local,
330
                self::replacePlaceholders($this->placeholders, $contents)
331
            );
332
333
            $this->bufferedFiles[$local] = $processedContents;
334
        }
335
336
        return $local;
337
    }
338
339
    public function getPhar(): Phar
340
    {
341
        return $this->phar;
342
    }
343
344
    /**
345
     * Signs the PHAR using a private key file.
346
     *
347
     * @param string $file     the private key file name
348
     * @param string $password the private key password
349
     */
350
    public function signUsingFile(string $file, string $password = null): void
351
    {
352
        $this->sign(file_contents($file), $password);
353
    }
354
355
    /**
356
     * Signs the PHAR using a private key.
357
     *
358
     * @param string $key      The private key
359
     * @param string $password The private key password
360
     */
361
    public function sign(string $key, ?string $password): void
362
    {
363
        $pubKey = $this->file.'.pubkey';
364
365
        Assertion::writeable(dirname($pubKey));
366
        Assertion::extensionLoaded('openssl');
367
368
        if (file_exists($pubKey)) {
369
            Assertion::file(
370
                $pubKey,
371
                'Cannot create public key: "%s" already exists and is not a file.'
372
            );
373
        }
374
375
        $resource = openssl_pkey_get_private($key, (string) $password);
376
377
        openssl_pkey_export($resource, $private);
378
379
        $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

379
        $details = openssl_pkey_get_details(/** @scrutinizer ignore-type */ $resource);
Loading history...
380
381
        $this->phar->setSignatureAlgorithm(Phar::OPENSSL, $private);
382
383
        dump_file($pubKey, $details['key']);
384
    }
385
386
    /**
387
     * @param string[] $files
388
     *
389
     * @return array array of tuples where the first element is the local file path (path inside the PHAR) and the
390
     *               second element is the processed contents
391
     */
392
    private function processContents(array $files): array
393
    {
394
        $mapFile = $this->mapFile;
395
        $placeholders = $this->placeholders;
396
        $compactors = $this->compactors;
397
        $bootstrap = $GLOBALS['_BOX_BOOTSTRAP'] ?? function (): void {};
398
        $cwd = getcwd();
399
400
        $processFile = function (string $file) use ($cwd, $mapFile, $placeholders, $compactors, $bootstrap): array {
401
            chdir($cwd);
402
            $bootstrap();
403
404
            $contents = file_contents($file);
405
406
            $local = $mapFile($file);
407
408
            $processedContents = self::compactContents(
409
                $compactors,
410
                $local,
411
                self::replacePlaceholders($placeholders, $contents)
412
            );
413
414
            return [$local, $processedContents];
415
        };
416
417
        return is_parallel_processing_enabled() && false === ($this->scoper instanceof NullScoper)
418
            ? wait(parallelMap($files, $processFile))
419
            : array_map($processFile, $files)
420
        ;
421
    }
422
423
    /**
424
     * Replaces the placeholders with their values.
425
     *
426
     * @param array  $placeholders
427
     * @param string $contents     the contents
428
     *
429
     * @return string the replaced contents
430
     */
431
    private static function replacePlaceholders(array $placeholders, string $contents): string
432
    {
433
        return str_replace(
434
            array_keys($placeholders),
435
            array_values($placeholders),
436
            $contents
437
        );
438
    }
439
440
    private static function compactContents(array $compactors, string $file, string $contents): string
441
    {
442
        return array_reduce(
443
            $compactors,
444
            function (string $contents, Compactor $compactor) use ($file): string {
445
                return $compactor->compact($file, $contents);
446
            },
447
            $contents
448
        );
449
    }
450
451
    /**
452
     * {@inheritdoc}
453
     */
454
    public function count(): int
455
    {
456
        Assertion::false($this->buffering, 'Cannot count the number of files in the PHAR when buffering');
457
458
        return $this->phar->count();
459
    }
460
}
461