Completed
Push — master ( c42975...dc9bb4 )
by Théo
02:20
created

Box_Extract::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 6
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 2
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
use Assert\Assertion;
16
17
/*
18
 * The open-ended stub pattern.
19
 *
20
 * @var string
21
 */
22
define('BOX_EXTRACT_PATTERN_OPEN', '__HALT'."_COMPILER(); ?>\r\n");
23
24
/**
25
 * Extracts a PHAR without the extension.
26
 *
27
 * This class is a rewrite of the `Extract_Phar` class that is included
28
 * in the default stub for all phars. The class is designed to work from
29
 * inside and outside of a phar. Unlike the original class, the stub
30
 * length must be specified.
31
 *
32
 * @see https://github.com/php/php-src/blob/master/ext/phar/shortarc.php
33
 */
34
final class Box_Extract
35
{
36
    /**
37
     * @var string The open-ended stub pattern
38
     */
39
    private const PATTERN_OPEN = BOX_EXTRACT_PATTERN_OPEN;
40
41
    /**
42
     * @var int The gzip compression flag
43
     */
44
    private const GZ = 0x1000;
45
46
    /**
47
     * @var int The bzip2 compression flag
48
     */
49
    private const BZ2 = 0x2000;
50
51
    /**
52
     * @var int
53
     */
54
    private const MASK = 0x3000;
55
56
    /**
57
     * @var string The PHAR file path to extract
58
     */
59
    private $file;
60
61
    /**
62
     * @var resource The open file handle
63
     */
64
    private $handle;
65
66
    /**
67
     * @var int The length of the stub in the PHAR
68
     */
69
    private $stub;
70
71
    public function __construct(string $file, int $stubLength)
72
    {
73
        Assertion::file($file);
74
75
        $this->file = $file;
76
        $this->stub = $stubLength;
77
    }
78
79
    /**
80
     * Finds the phar's stub length using the end pattern.
81
     *
82
     * A "pattern" is a sequence of characters that indicate the end of a
83
     * stub, and the beginning of a manifest. This determines the complete
84
     * size of a stub, and is used as an offset to begin parsing the data
85
     * contained in the phar's manifest.
86
     *
87
     * The stub generated included with the Box library uses what I like
88
     * to call an open-ended pattern. This pattern uses the function
89
     * "__HALT_COMPILER();" at the end, with no following whitespace or
90
     * closing PHP tag. By default, this method will use that pattern,
91
     * defined as `Extract::PATTERN_OPEN`.
92
     *
93
     * The Phar class generates its own default stub. The pattern for the
94
     * default stub is slightly different than the one used by Box. This
95
     * pattern is defined as `Extract::PATTERN_DEFAULT`.
96
     *
97
     * If you have used your own custom stub, you will need to specify its
98
     * pattern as the `$pattern` argument, if you cannot use either of the
99
     * pattern constants defined.
100
     *
101
     * @param string $file    The PHAR file path
102
     * @param string $pattern The stub end pattern
103
     *
104
     * @return int The stub length
105
     */
106
    public static function findStubLength(
107
        string $file,
108
        string $pattern = self::PATTERN_OPEN
109
    ): int {
110
        Assertion::file($file);
111
        Assertion::readable($file);
112
113
        $fp = fopen($file, 'rb');
114
115
        $stub = null;
116
        $offset = 0;
117
        $combo = str_split($pattern);
118
119
        while (!feof($fp)) {
0 ignored issues
show
Bug introduced by
It seems like $fp can also be of type false; however, parameter $handle of feof() 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

119
        while (!feof(/** @scrutinizer ignore-type */ $fp)) {
Loading history...
120
            if (fgetc($fp) === $combo[$offset]) {
0 ignored issues
show
Bug introduced by
It seems like $fp can also be of type false; however, parameter $handle of fgetc() 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

120
            if (fgetc(/** @scrutinizer ignore-type */ $fp) === $combo[$offset]) {
Loading history...
121
                ++$offset;
122
123
                if (!isset($combo[$offset])) {
124
                    $stub = ftell($fp);
0 ignored issues
show
Bug introduced by
It seems like $fp can also be of type false; however, parameter $handle of ftell() 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

124
                    $stub = ftell(/** @scrutinizer ignore-type */ $fp);
Loading history...
125
126
                    break;
127
                }
128
            } else {
129
                $offset = 0;
130
            }
131
        }
132
133
        fclose($fp);
0 ignored issues
show
Bug introduced by
It seems like $fp can also be of type false; however, parameter $handle of fclose() 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

133
        fclose(/** @scrutinizer ignore-type */ $fp);
Loading history...
134
135
        if (null === $stub) {
136
            throw new InvalidArgumentException(
137
                sprintf(
138
                    'The pattern could not be found in "%s".',
139
                    $file
140
                )
141
            );
142
        }
143
144
        return $stub;
145
    }
146
147
    /**
148
     * Extracts the PHAR to the directory path.
149
     *
150
     * If no directory path is given, a temporary one will be generated and
151
     * returned. If a directory path is given, the returned directory path
152
     * will be the same.
153
     *
154
     * @param string $dir The directory to extract to
155
     *
156
     * @return string The directory extracted to
157
     */
158
    public function go(string $dir = null): string
159
    {
160
        // Set up the output directory
161
        if (null === $dir) {
162
            $dir = rtrim(sys_get_temp_dir(), '\\/')
163
                .DIRECTORY_SEPARATOR
164
                .'pharextract'
165
                .DIRECTORY_SEPARATOR
166
                .basename($this->file, '.phar');
167
        } else {
168
            $dir = realpath($dir);
169
        }
170
171
        // Skip if already extracted
172
        $md5 = $dir.DIRECTORY_SEPARATOR.md5_file($this->file);
173
174
        if (file_exists($md5)) {
175
            return $dir;
176
        }
177
178
        if (!is_dir($dir)) {
179
            $this->createDir($dir);
180
        }
181
182
        // Open the file and skip stub
183
        $this->open();
184
185
        if (-1 === fseek($this->handle, $this->stub)) {
0 ignored issues
show
Bug introduced by
It seems like $this->handle can also be of type false; however, parameter $handle of fseek() 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

185
        if (-1 === fseek(/** @scrutinizer ignore-type */ $this->handle, $this->stub)) {
Loading history...
186
            throw new RuntimeException(
187
                sprintf(
188
                    'Could not seek to %d in the file "%s".',
189
                    $this->stub,
190
                    $this->file
191
                )
192
            );
193
        }
194
195
        // Read the manifest
196
        $info = $this->readManifest();
197
198
        if ($info['flags'] & self::GZ) {
199
            if (!function_exists('gzinflate')) {
200
                throw new RuntimeException(
201
                    'The zlib extension is (gzinflate()) is required for "%s.',
202
                    $this->file
0 ignored issues
show
Bug introduced by
$this->file of type string is incompatible with the type integer expected by parameter $code of RuntimeException::__construct(). ( Ignorable by Annotation )

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

202
                    /** @scrutinizer ignore-type */ $this->file
Loading history...
203
                );
204
            }
205
        }
206
207
        if ($info['flags'] & self::BZ2) {
208
            if (!function_exists('bzdecompress')) {
209
                throw new RuntimeException(
210
                    'The bzip2 extension (bzdecompress()) is required for "%s".',
211
                    $this->file
212
                );
213
            }
214
        }
215
216
        self::purge($dir);
217
218
        $this->createDir($dir);
219
        $this->createFile($md5);
220
221
        foreach ($info['files'] as $info) {
222
            $path = $dir.DIRECTORY_SEPARATOR.$info['path'];
223
            $parent = dirname($path);
224
225
            if (!is_dir($parent)) {
226
                $this->createDir($parent);
227
            }
228
229
            if (preg_match('{/$}', $info['path'])) {
230
                $this->createDir($path, 0777, false);
231
            } else {
232
                $this->createFile(
233
                    $path,
234
                    $this->extractFile($info)
235
                );
236
            }
237
        }
238
239
        return $dir;
240
    }
241
242
    /**
243
     * Recursively deletes the directory or file path.
244
     *
245
     * @param string $path The path to delete
246
     */
247
    public static function purge(string $path): void
248
    {
249
        if (is_dir($path)) {
250
            foreach (scandir($path) as $item) {
251
                if (('.' === $item) || ('..' === $item)) {
252
                    continue;
253
                }
254
255
                self::purge($path.DIRECTORY_SEPARATOR.$item);
256
            }
257
258
            if (!rmdir($path)) {
259
                throw new RuntimeException(
260
                    sprintf(
261
                        'The directory "%s" could not be deleted.',
262
                        $path
263
                    )
264
                );
265
            }
266
        } else {
267
            if (!unlink($path)) {
268
                throw new RuntimeException(
269
                    sprintf(
270
                        'The file "%s" could not be deleted.',
271
                        $path
272
                    )
273
                );
274
            }
275
        }
276
    }
277
278
    /**
279
     * Creates a new directory.
280
     *
281
     * @param string $path      The directory path
282
     * @param int    $chmod     The file mode
283
     * @param bool   $recursive Recursively create path?
284
     *
285
     * @throws RuntimeException if the path could not be created
286
     */
287
    private function createDir(string $path, int $chmod = 0777, bool $recursive = true): void
288
    {
289
        if (!mkdir($path, $chmod, $recursive)) {
290
            throw new RuntimeException(
291
                sprintf(
292
                    'The directory path "%s" could not be created.',
293
                    $path
294
                )
295
            );
296
        }
297
    }
298
299
    /**
300
     * Creates a new file.
301
     *
302
     * @param string $path     The file path
303
     * @param string $contents The file contents
304
     * @param int    $mode     The file mode
305
     */
306
    private function createFile($path, $contents = '', $mode = 0666): void
307
    {
308
        if (false === file_put_contents($path, $contents)) {
309
            throw new RuntimeException(
310
                sprintf(
311
                    'The file "%s" could not be written.',
312
                    $path
313
                )
314
            );
315
        }
316
317
        if (!chmod($path, $mode)) {
318
            throw new RuntimeException(
319
                sprintf(
320
                    'The file "%s" could not be chmodded to %o.',
321
                    $path,
322
                    $mode
323
                )
324
            );
325
        }
326
    }
327
328
    /**
329
     * Extracts a single file from the PHAR.
330
     *
331
     * @param array $info The file information
332
     *
333
     * @return string The file data
334
     */
335
    private function extractFile(array $info): string
336
    {
337
        if (0 === $info['size']) {
338
            return '';
339
        }
340
341
        $data = $this->read($info['compressed_size']);
342
343
        if ($info['flags'] & self::GZ) {
344
            if (false === ($data = gzinflate($data))) {
0 ignored issues
show
introduced by
The condition false === $data = gzinflate($data) can never be true.
Loading history...
345
                throw new RuntimeException(
346
                    sprintf(
347
                        'The "%s" file could not be inflated (gzip) from "%s".',
348
                        $info['path'],
349
                        $this->file
350
                    )
351
                );
352
            }
353
        } elseif ($info['flags'] & self::BZ2) {
354
            if (false === ($data = bzdecompress($data))) {
355
                throw new RuntimeException(
356
                    sprintf(
357
                        'The "%s" file could not be inflated (bzip2) from "%s".',
358
                        $info['path'],
359
                        $this->file
360
                    )
361
                );
362
            }
363
        }
364
365
        if (($actual = strlen($data)) !== $info['size']) {
366
            throw new UnexpectedValueException(
367
                sprintf(
368
                    'The size of "%s" (%d) did not match what was expected (%d) in "%s".',
369
                    $info['path'],
370
                    $actual,
371
                    $info['size'],
372
                    $this->file
373
                )
374
            );
375
        }
376
377
        $crc32 = sprintf('%u', crc32($data) & 0xffffffff);
378
379
        if ($info['crc32'] != $crc32) {
380
            throw new UnexpectedValueException(
381
                sprintf(
382
                    'The crc32 checksum (%s) for "%s" did not match what was expected (%s) in "%s".',
383
                    $crc32,
384
                    $info['path'],
385
                    $info['crc32'],
386
                    $this->file
387
                )
388
            );
389
        }
390
391
        return $data;
392
    }
393
394
    /**
395
     * Opens the file for reading.
396
     */
397
    private function open(): void
398
    {
399
        if (null === ($this->handle = fopen($this->file, 'rb'))) {
0 ignored issues
show
introduced by
The condition null === $this->handle = fopen($this->file, 'rb') can never be true.
Loading history...
Documentation Bug introduced by
It seems like fopen($this->file, 'rb') can also be of type false. However, the property $handle is declared as type resource. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
400
            $this->handle = null;
401
402
            throw new RuntimeException(
403
                sprintf(
404
                    'The file "%s" could not be opened for reading.',
405
                    $this->file
406
                )
407
            );
408
        }
409
    }
410
411
    /**
412
     * Reads the number of bytes from the file.
413
     *
414
     * @param int $bytes The number of bytes
415
     *
416
     * @return string The binary string read
417
     */
418
    private function read(int $bytes): string
419
    {
420
        $read = '';
421
        $total = $bytes;
422
423
        while (!feof($this->handle) && $bytes) {
424
            if (false === ($chunk = fread($this->handle, $bytes))) {
0 ignored issues
show
introduced by
The condition false === $chunk = fread($this->handle, $bytes) can never be true.
Loading history...
425
                throw new RuntimeException(
426
                    sprintf(
427
                        'Could not read %d bytes from "%s".',
428
                        $bytes,
429
                        $this->file
430
                    )
431
                );
432
            }
433
434
            $read .= $chunk;
435
            $bytes -= strlen($chunk);
436
        }
437
438
        if (($actual = strlen($read)) !== $total) {
439
            throw new RuntimeException(
440
                sprintf(
441
                    'Only read %d of %d in "%s".',
442
                    $actual,
443
                    $total,
444
                    $this->file
445
                )
446
            );
447
        }
448
449
        return $read;
450
    }
451
452
    /**
453
     * Reads and unpacks the manifest data from the phar.
454
     *
455
     * @return array the manifest
456
     */
457
    private function readManifest(): array
458
    {
459
        $size = unpack('V', $this->read(4));
460
        $size = $size[1];
461
462
        $raw = $this->read($size);
463
464
        // ++ start skip: API version, global flags, alias, and metadata
465
        $count = unpack('V', substr($raw, 0, 4));
466
        $count = $count[1];
467
468
        $aliasSize = unpack('V', substr($raw, 10, 4));
469
        $aliasSize = $aliasSize[1];
470
        $raw = substr($raw, 14 + $aliasSize);
471
472
        $metaSize = unpack('V', substr($raw, 0, 4));
473
        $metaSize = $metaSize[1];
474
475
        $offset = 0;
476
        $start = 4 + $metaSize;
477
        // -- end skip
478
479
        $manifest = [
480
            'files' => [],
481
            'flags' => 0,
482
        ];
483
484
        for ($i = 0; $i < $count; ++$i) {
485
            $length = unpack('V', substr($raw, $start, 4));
486
            $length = $length[1];
487
            $start += 4;
488
489
            $path = substr($raw, $start, $length);
490
            $start += $length;
491
492
            $file = unpack(
493
                'Vsize/Vtimestamp/Vcompressed_size/Vcrc32/Vflags/Vmetadata_length',
494
                substr($raw, $start, 24)
495
            );
496
497
            $file['path'] = $path;
498
            $file['crc32'] = sprintf('%u', $file['crc32'] & 0xffffffff);
499
            $file['offset'] = $offset;
500
501
            $offset += $file['compressed_size'];
502
            $start += 24 + $file['metadata_length'];
503
504
            $manifest['flags'] |= $file['flags'] & self::MASK;
505
506
            $manifest['files'][] = $file;
507
        }
508
509
        return $manifest;
510
    }
511
}
512