Passed
Pull Request — master (#247)
by Théo
02:40
created

Box::processContents()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 23
Code Lines 14

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

372
        $details = openssl_pkey_get_details(/** @scrutinizer ignore-type */ $resource);
Loading history...
373
374
        $this->phar->setSignatureAlgorithm(Phar::OPENSSL, $private);
375
376
        dump_file($pubKey, $details['key']);
377
    }
378
379
    /**
380
     * @param string[] $files
381
     *
382
     * @return array array of tuples where the first element is the local file path (path inside the PHAR) and the
383
     *               second element is the processed contents
384
     */
385
    private function processContents(array $files): array
386
    {
387
        $mapFile = $this->mapFile;
388
        $compactors = $this->compactors;
389
        $bootstrap = $GLOBALS['_BOX_BOOTSTRAP'] ?? function (): void {};
390
        $cwd = getcwd();
391
392
        $processFile = function (string $file) use ($cwd, $mapFile, $compactors, $bootstrap): array {
393
            chdir($cwd);
394
            $bootstrap();
395
396
            $contents = file_contents($file);
397
398
            $local = $mapFile($file);
399
400
            $processedContents = $compactors->compactContents($local, $contents);
401
402
            return [$local, $processedContents];
403
        };
404
405
        return is_parallel_processing_enabled() && false === ($this->scoper instanceof NullScoper)
406
            ? wait(parallelMap($files, $processFile))
407
            : array_map($processFile, $files)
408
        ;
409
    }
410
411
    /**
412
     * {@inheritdoc}
413
     */
414
    public function count(): int
415
    {
416
        Assertion::false($this->buffering, 'Cannot count the number of files in the PHAR when buffering');
417
418
        return $this->phar->count();
419
    }
420
}
421