Issues (224)

src/Box.php (1 issue)

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\Parallel\Worker\TaskFailureThrowable;
18
use BadMethodCallException;
19
use Countable;
20
use DateTimeImmutable;
21
use Fidry\FileSystem\FS;
22
use Humbug\PhpScoper\Symbol\SymbolsRegistry;
23
use KevinGH\Box\Compactor\Compactors;
24
use KevinGH\Box\Compactor\PhpScoper;
25
use KevinGH\Box\Compactor\Placeholder;
26
use KevinGH\Box\Parallelization\ParallelFileProcessor;
27
use KevinGH\Box\Parallelization\ParallelizationDecider;
28
use KevinGH\Box\Phar\CompressionAlgorithm;
29
use KevinGH\Box\Phar\SigningAlgorithm;
30
use KevinGH\Box\PhpScoper\NullScoper;
31
use KevinGH\Box\PhpScoper\Scoper;
32
use Phar;
33
use RecursiveDirectoryIterator;
34
use RuntimeException;
35
use Seld\PharUtils\Timestamps;
36
use SplFileInfo;
37
use Webmozart\Assert\Assert;
38
use function array_map;
39
use function array_unshift;
40
use function chdir;
41
use function dirname;
42
use function extension_loaded;
43
use function file_exists;
44
use function getcwd;
45
use function is_object;
46
use function openssl_pkey_export;
47
use function openssl_pkey_get_details;
48
use function openssl_pkey_get_private;
49
use function sprintf;
50
51
/**
52
 * Box is a utility class to generate a PHAR.
53
 *
54
 * @private
55
 */
56
final class Box implements Countable
57
{
58
    private Compactors $compactors;
59
    private Placeholder $placeholderCompactor;
60
    private MapFile $mapFile;
61
    private Scoper $scoper;
62
    private bool $buffering = false;
63
64
    /**
65
     * @var array<string, string> Relative file path as key and file contents as value
66
     */
67
    private array $bufferedFiles = [];
68
69
    private function __construct(
70
        private Phar $phar,
71
        private readonly string $pharFilePath,
72
        private readonly bool $enableParallelization,
73
    ) {
74
        $this->compactors = new Compactors();
75
        $this->placeholderCompactor = new Placeholder([]);
76
        $this->mapFile = new MapFile(getcwd(), []);
77
        $this->scoper = new NullScoper();
78
    }
79
80
    /**
81
     * Creates a new PHAR and Box instance.
82
     *
83
     * @param string $pharFilePath The PHAR file name
84
     * @param int    $pharFlags    Flags to pass to the Phar parent class RecursiveDirectoryIterator
85
     * @param string $pharAlias    Alias with which the Phar archive should be referred to in calls to stream functionality
86
     *
87
     * @see RecursiveDirectoryIterator
88
     */
89
    public static function create(
90
        string $pharFilePath,
91
        int $pharFlags = 0,
92
        ?string $pharAlias = null,
93
        bool $enableParallelization = false,
94
    ): self {
95
        // Ensure the parent directory of the PHAR file exists as `new \Phar()` does not create it and would fail
96
        // otherwise.
97
        FS::mkdir(dirname($pharFilePath));
98
99
        return new self(
100
            new Phar($pharFilePath, $pharFlags, $pharAlias),
101
            $pharFilePath,
102
            $enableParallelization,
103
        );
104
    }
105
106
    public function startBuffering(): void
107
    {
108
        Assert::false($this->buffering, 'The buffering must be ended before starting it again');
109
110
        $this->buffering = true;
111
112
        $this->phar->startBuffering();
113
    }
114
115
    /**
116
     * @param callable(SymbolsRegistry, string): void $dumpAutoload
117
     */
118
    public function endBuffering(?callable $dumpAutoload): void
119
    {
120
        Assert::true($this->buffering, 'The buffering must be started before ending it');
121
122
        $dumpAutoload ??= static fn () => null;
123
        $cwd = getcwd();
124
125
        $tmp = FS::makeTmpDir('box', self::class);
126
        chdir($tmp);
127
128
        if ([] === $this->bufferedFiles) {
129
            $this->bufferedFiles = [
130
                '.box_empty' => 'A PHAR cannot be empty so Box adds this file to ensure the PHAR is created still.',
131
            ];
132
        }
133
134
        try {
135
            foreach ($this->bufferedFiles as $file => $contents) {
136
                FS::dumpFile($file, $contents);
137
            }
138
139
            if (null !== $dumpAutoload) {
140
                $dumpAutoload(
141
                    $this->scoper->getSymbolsRegistry(),
142
                    $this->scoper->getPrefix(),
143
                    $this->scoper->getExcludedFilePaths(),
144
                );
145
            }
146
147
            chdir($cwd);
148
149
            $this->phar->buildFromDirectory($tmp);
150
        } finally {
151
            FS::remove($tmp);
152
        }
153
154
        $this->buffering = false;
155
156
        $this->phar->stopBuffering();
157
    }
158
159
    /**
160
     * @param non-empty-string $normalizedVendorDir Normalized path ("/" path separator and no trailing "/") to the Composer vendor directory
161
     */
162
    public function removeComposerArtifacts(string $normalizedVendorDir): void
163
    {
164
        Assert::false($this->buffering, 'The buffering must have ended before removing the Composer artefacts');
165
166
        $composerArtifacts = [
167
            'composer.json',
168
            'composer.lock',
169
            $normalizedVendorDir.'/composer/installed.json',
170
        ];
171
172
        $this->phar->startBuffering();
173
174
        foreach ($composerArtifacts as $composerArtifact) {
175
            $localComposerArtifact = ($this->mapFile)($composerArtifact);
176
177
            $pharFilePath = sprintf(
178
                'phar://%s/%s',
179
                $this->phar->getPath(),
180
                $localComposerArtifact,
181
            );
182
183
            if (file_exists($pharFilePath)) {
184
                $this->phar->delete($localComposerArtifact);
185
            }
186
        }
187
188
        $this->phar->stopBuffering();
189
    }
190
191
    public function compress(CompressionAlgorithm $compressionAlgorithm): ?string
192
    {
193
        Assert::false($this->buffering, 'Cannot compress files while buffering.');
194
195
        $extensionRequired = $compressionAlgorithm->getRequiredExtension();
196
197
        if (null !== $extensionRequired && false === extension_loaded($extensionRequired)) {
198
            throw new RuntimeException(
199
                sprintf(
200
                    'Cannot compress the PHAR with the compression algorithm "%s": the extension "%s" is required but appear to not be loaded',
201
                    $compressionAlgorithm->name,
202
                    $extensionRequired,
203
                ),
204
            );
205
        }
206
207
        try {
208
            if (CompressionAlgorithm::NONE === $compressionAlgorithm) {
209
                $this->phar->decompressFiles();
210
            } else {
211
                $this->phar->compressFiles($compressionAlgorithm->value);
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 your system limits or install the posix extension to allow Box to automatically configure it during the compression',
218
                    $this->phar->count(),
219
                );
220
221
            throw new RuntimeException($exceptionMessage, $exception->getCode(), $exception);
222
        }
223
224
        return $extensionRequired;
225
    }
226
227
    public function registerCompactors(Compactors $compactors): void
228
    {
229
        $compactorsArray = $compactors->toArray();
230
231
        foreach ($compactorsArray as $index => $compactor) {
232
            if ($compactor instanceof PhpScoper) {
233
                $this->scoper = $compactor->getScoper();
234
235
                continue;
236
            }
237
238
            if ($compactor instanceof Placeholder) {
239
                // Removes the known Placeholder compactors in favour of the Box one
240
                unset($compactorsArray[$index]);
241
            }
242
        }
243
244
        array_unshift($compactorsArray, $this->placeholderCompactor);
245
246
        $this->compactors = new Compactors(...$compactorsArray);
247
    }
248
249
    /**
250
     * @param scalar[] $placeholders
251
     */
252
    public function registerPlaceholders(array $placeholders): void
253
    {
254
        $message = 'Expected value "%s" to be a scalar or stringable object.';
255
256
        foreach ($placeholders as $index => $placeholder) {
257
            if (is_object($placeholder)) {
258
                Assert::methodExists($placeholder, '__toString', $message);
259
260
                $placeholders[$index] = (string) $placeholder;
261
262
                break;
263
            }
264
265
            Assert::scalar($placeholder, $message);
266
        }
267
268
        $this->placeholderCompactor = new Placeholder($placeholders);
269
270
        $this->registerCompactors($this->compactors);
271
    }
272
273
    public function registerFileMapping(MapFile $fileMapper): void
274
    {
275
        $this->mapFile = $fileMapper;
276
    }
277
278
    public function registerStub(string $file): void
279
    {
280
        $contents = $this->placeholderCompactor->compact(
281
            $file,
282
            FS::getFileContents($file),
283
        );
284
285
        $this->phar->setStub($contents);
286
    }
287
288
    /**
289
     * @param array<SplFileInfo|string> $files
290
     *
291
     * @throws TaskFailureThrowable
292
     */
293
    public function addFiles(array $files, bool $binary): void
294
    {
295
        Assert::true($this->buffering, 'Cannot add files if the buffering has not started.');
296
297
        $files = array_map('strval', $files);
298
299
        if ($binary) {
300
            foreach ($files as $file) {
301
                $this->addFile($file, null, true);
302
            }
303
304
            return;
305
        }
306
307
        foreach ($this->processContents($files) as [$file, $contents]) {
308
            $this->bufferedFiles[$file] = $contents;
309
        }
310
    }
311
312
    /**
313
     * Adds the a file to the PHAR. The contents will first be compacted and have its placeholders
314
     * replaced.
315
     *
316
     * @param null|string $contents If null the content of the file will be used
317
     * @param bool        $binary   When true means the file content shouldn't be processed
318
     *
319
     * @return string File local path
320
     */
321
    public function addFile(string $file, ?string $contents = null, bool $binary = false): string
322
    {
323
        Assert::true($this->buffering, 'Cannot add files if the buffering has not started.');
324
325
        if (null === $contents) {
326
            $contents = FS::getFileContents($file);
327
        }
328
329
        $local = ($this->mapFile)($file);
330
331
        $this->bufferedFiles[$local] = $binary ? $contents : $this->compactors->compact($local, $contents);
332
333
        return $local;
334
    }
335
336
    /**
337
     * @internal
338
     */
339
    public function getPhar(): Phar
340
    {
341
        return $this->phar;
342
    }
343
344
    public function setAlias(string $alias): void
345
    {
346
        $aliasWasAdded = $this->phar->setAlias($alias);
347
348
        Assert::true(
349
            $aliasWasAdded,
350
            sprintf(
351
                'The alias "%s" is invalid. See Phar::setAlias() documentation for more information.',
352
                $alias,
353
            ),
354
        );
355
    }
356
357
    public function setStub(string $stub): void
358
    {
359
        $this->phar->setStub($stub);
360
    }
361
362
    public function setDefaultStub(string $main): void
363
    {
364
        $this->phar->setDefaultStub($main);
365
    }
366
367
    public function setMetadata(mixed $metadata): void
368
    {
369
        $this->phar->setMetadata($metadata);
370
    }
371
372
    public function extractTo(string $directory, bool $overwrite = false): void
373
    {
374
        $this->phar->extractTo($directory, overwrite: $overwrite);
375
    }
376
377
    public function sign(
378
        SigningAlgorithm $signingAlgorithm,
379
        ?DateTimeImmutable $timestamp = null,
380
    ): void {
381
        if (null === $timestamp) {
382
            $this->phar->setSignatureAlgorithm($signingAlgorithm->value);
383
384
            return;
385
        }
386
387
        $phar = $this->phar;
388
        $phar->__destruct();
389
        unset($this->phar);
390
391
        $util = new Timestamps($this->pharFilePath);
392
        $util->updateTimestamps($timestamp);
393
        $util->save(
394
            $this->pharFilePath,
395
            $signingAlgorithm->value,
396
        );
397
398
        $this->phar = new Phar($this->pharFilePath);
399
    }
400
401
    /**
402
     * Signs the PHAR using a private key file.
403
     *
404
     * @param string      $file     the private key file name
405
     * @param null|string $password the private key password
406
     */
407
    public function signUsingFile(string $file, ?string $password = null): void
408
    {
409
        $this->signUsingKey(FS::getFileContents($file), $password);
410
    }
411
412
    /**
413
     * Signs the PHAR using a private key.
414
     *
415
     * @param string      $key      The private key
416
     * @param null|string $password The private key password
417
     */
418
    public function signUsingKey(string $key, ?string $password): void
419
    {
420
        $pubKey = $this->pharFilePath.'.pubkey';
421
422
        Assert::writable(dirname($pubKey));
423
        Assert::true(extension_loaded('openssl'));
424
425
        if (file_exists($pubKey)) {
426
            Assert::file(
427
                $pubKey,
428
                'Cannot create public key: %s already exists and is not a file.',
429
            );
430
        }
431
432
        $resource = openssl_pkey_get_private($key, (string) $password);
433
434
        Assert::notSame(false, $resource, 'Could not retrieve the private key, check that the password is correct.');
435
436
        openssl_pkey_export($resource, $private);
437
438
        $details = openssl_pkey_get_details($resource);
439
440
        $this->phar->setSignatureAlgorithm(Phar::OPENSSL, $private);
441
442
        FS::dumpFile($pubKey, $details['key']);
443
    }
444
445
    /**
446
     * @param string[] $files
447
     *
448
     * @return array array of tuples where the first element is the local file path (path inside the PHAR) and the
449
     *               second element is the processed contents
450
     */
451
    private function processContents(array $files): array
452
    {
453
        $cwd = getcwd();
454
455
        $shouldProcessFilesInParallel = $this->enableParallelization && ParallelizationDecider::shouldProcessFilesInParallel(
456
            $this->scoper,
457
            count($files),
458
        );
459
460
        $processFiles = $shouldProcessFilesInParallel
461
            ? ParallelFileProcessor::processFilesInParallel(...)
462
            : self::processFilesSynchronously(...);
463
464
        return $processFiles(
465
            $files,
466
            $cwd,
467
            $this->mapFile,
468
            $this->compactors,
469
        );
470
    }
471
472
    /**
473
     * @param string[] $files
474
     *
475
     * @return list<array{string, string}>
476
     */
477
    private static function processFilesSynchronously(
478
        array $files,
479
        string $_cwd,
480
        MapFile $mapFile,
481
        Compactors $compactors,
482
    ): array {
483
        $processFile = static function (string $file) use ($mapFile, $compactors): array {
484
            $contents = FS::getFileContents($file);
485
486
            $local = $mapFile($file);
487
488
            $processedContents = $compactors->compact($local, $contents);
489
490
            return [
491
                $local,
492
                $processedContents,
493
            ];
494
        };
495
496
        return array_map($processFile, $files);
0 ignored issues
show
Bug Best Practice introduced by
The expression return array_map($processFile, $files) returns the type array which is incompatible with the documented return type KevinGH\Box\list.
Loading history...
497
    }
498
499
    public function count(): int
500
    {
501
        Assert::false($this->buffering, 'Cannot count the number of files in the PHAR when buffering');
502
503
        return $this->phar->count();
504
    }
505
}
506