Completed
Push — master ( bc7d7f...7a9596 )
by Théo
02:46
created

Extract::extractFile()   B

Complexity

Conditions 8
Paths 12

Size

Total Lines 57
Code Lines 35

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 57
c 0
b 0
f 0
rs 7.2648
cc 8
eloc 35
nc 12
nop 1

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
// TODO: placing a declare statement here, e.g. for strict types, currently breaks everything. Need to find out why.
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 InvalidArgumentException;
18
use LengthException;
19
use RuntimeException;
20
use UnexpectedValueException;
21
22
/*
23
 * The default stub pattern.
24
 *
25
 * @var string
26
 */
27
define('BOX_EXTRACT_PATTERN_DEFAULT', '__HALT'.'_COMPILER(); ?>');
28
29
/*
30
 * The open-ended stub pattern.
31
 *
32
 * @var string
33
 */
34
define('BOX_EXTRACT_PATTERN_OPEN', '__HALT'."_COMPILER(); ?>\r\n");
35
36
/**
37
 * Extracts a phar without the extension.
38
 *
39
 * This class is a rewrite of the `Extract_Phar` class that is included
40
 * in the default stub for all phars. The class is designed to work from
41
 * inside and outside of a phar. Unlike the original class, the stub
42
 * length must be specified.
43
 *
44
 * @author Kevin Herrera <[email protected]>
45
 *
46
 * @see https://github.com/php/php-src/blob/master/ext/phar/shortarc.php
47
 */
48
class Extract
49
{
50
    /**
51
     * The default stub pattern.
52
     *
53
     * @var string
54
     */
55
    const PATTERN_DEFAULT = BOX_EXTRACT_PATTERN_DEFAULT;
56
57
    /**
58
     * The open-ended stub pattern.
59
     *
60
     * @var string
61
     */
62
    const PATTERN_OPEN = BOX_EXTRACT_PATTERN_OPEN;
63
64
    /**
65
     * The gzip compression flag.
66
     *
67
     * @var int
68
     */
69
    const GZ = 0x1000;
70
71
    /**
72
     * The bzip2 compression flag.
73
     *
74
     * @var int
75
     */
76
    const BZ2 = 0x2000;
77
78
    /**
79
     * @var int
80
     */
81
    const MASK = 0x3000;
82
83
    /**
84
     * The phar file path to extract.
85
     *
86
     * @var string
87
     */
88
    private $file;
89
90
    /**
91
     * The open file handle.
92
     *
93
     * @var resource
94
     */
95
    private $handle;
96
97
    /**
98
     * The length of the stub in the phar.
99
     *
100
     * @var int
101
     */
102
    private $stub;
103
104
    /**
105
     * Sets the file to extract and the stub length.
106
     *
107
     * @param string $file the file path
108
     * @param int    $stub the stub length
109
     *
110
     * @throws InvalidArgumentException if the file does not exist
111
     */
112
    public function __construct($file, $stub)
113
    {
114
        if (!is_file($file)) {
115
            throw new InvalidArgumentException(
116
                sprintf(
117
                    'The path "%s" is not a file or does not exist.',
118
                    $file
119
                )
120
            );
121
        }
122
123
        $this->file = $file;
124
        $this->stub = $stub;
125
    }
126
127
    /**
128
     * Finds the phar's stub length using the end pattern.
129
     *
130
     * A "pattern" is a sequence of characters that indicate the end of a
131
     * stub, and the beginning of a manifest. This determines the complete
132
     * size of a stub, and is used as an offset to begin parsing the data
133
     * contained in the phar's manifest.
134
     *
135
     * The stub generated included with the Box library uses what I like
136
     * to call an open-ended pattern. This pattern uses the function
137
     * "__HALT_COMPILER();" at the end, with no following whitespace or
138
     * closing PHP tag. By default, this method will use that pattern,
139
     * defined as `Extract::PATTERN_OPEN`.
140
     *
141
     * The Phar class generates its own default stub. The pattern for the
142
     * default stub is slightly different than the one used by Box. This
143
     * pattern is defined as `Extract::PATTERN_DEFAULT`.
144
     *
145
     * If you have used your own custom stub, you will need to specify its
146
     * pattern as the `$pattern` argument, if you cannot use either of the
147
     * pattern constants defined.
148
     *
149
     * @param string $file    the phar file path
150
     * @param string $pattern the stub end pattern
151
     *
152
     * @throws InvalidArgumentException if the pattern could not be found
153
     * @throws RuntimeException         if the phar could not be read
154
     *
155
     * @return int the stub length
156
     */
157
    public static function findStubLength(
158
        $file,
159
        $pattern = self::PATTERN_OPEN
160
    ) {
161
        if (!($fp = fopen($file, 'rb'))) {
162
            throw new RuntimeException(
163
                sprintf(
164
                    'The phar "%s" could not be opened for reading.',
165
                    $file
166
                )
167
            );
168
        }
169
170
        $stub = null;
171
        $offset = 0;
172
        $combo = str_split($pattern);
173
174
        while (!feof($fp)) {
175
            if (fgetc($fp) === $combo[$offset]) {
176
                $offset++;
177
178
                if (!isset($combo[$offset])) {
179
                    $stub = ftell($fp);
180
181
                    break;
182
                }
183
            } else {
184
                $offset = 0;
185
            }
186
        }
187
188
        fclose($fp);
189
190
        if (null === $stub) {
191
            throw new InvalidArgumentException(
192
                sprintf(
193
                    'The pattern could not be found in "%s".',
194
                    $file
195
                )
196
            );
197
        }
198
199
        return $stub;
200
    }
201
202
    /**
203
     * Extracts the phar to the directory path.
204
     *
205
     * If no directory path is given, a temporary one will be generated and
206
     * returned. If a directory path is given, the returned directory path
207
     * will be the same.
208
     *
209
     * @param string $dir the directory to extract to
210
     *
211
     * @throws LengthException
212
     * @throws RuntimeException
213
     *
214
     * @return string the directory extracted to
215
     */
216
    public function go($dir = null)
217
    {
218
        // set up the output directory
219
        if (null === $dir) {
220
            $dir = rtrim(sys_get_temp_dir(), '\\/')
221
                .DIRECTORY_SEPARATOR
222
                .'pharextract'
223
                .DIRECTORY_SEPARATOR
224
                .basename($this->file, '.phar');
225
        } else {
226
            $dir = realpath($dir);
227
        }
228
229
        // skip if already extracted
230
        $md5 = $dir.DIRECTORY_SEPARATOR.md5_file($this->file);
0 ignored issues
show
Bug introduced by
Are you sure $dir of type false|string can be used in concatenation? ( Ignorable by Annotation )

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

230
        $md5 = /** @scrutinizer ignore-type */ $dir.DIRECTORY_SEPARATOR.md5_file($this->file);
Loading history...
231
232
        if (file_exists($md5)) {
233
            return $dir;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $dir could also return false which is incompatible with the documented return type string. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
234
        }
235
236
        if (!is_dir($dir)) {
0 ignored issues
show
Bug introduced by
It seems like $dir can also be of type false; however, parameter $filename of is_dir() does only seem to accept string, 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

236
        if (!is_dir(/** @scrutinizer ignore-type */ $dir)) {
Loading history...
237
            $this->createDir($dir);
0 ignored issues
show
Bug introduced by
It seems like $dir can also be of type false; however, parameter $path of KevinGH\Box\Extract::createDir() does only seem to accept string, 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

237
            $this->createDir(/** @scrutinizer ignore-type */ $dir);
Loading history...
238
        }
239
240
        // open the file and skip stub
241
        $this->open();
242
243
        if (-1 === fseek($this->handle, $this->stub)) {
244
            throw new RuntimeException(
245
                sprintf(
246
                    'Could not seek to %d in the file "%s".',
247
                    $this->stub,
248
                    $this->file
249
                )
250
            );
251
        }
252
253
        // read the manifest
254
        $info = $this->readManifest();
255
256
        if ($info['flags'] & self::GZ) {
257
            if (!function_exists('gzinflate')) {
258
                throw new RuntimeException(
259
                    'The zlib extension is (gzinflate()) is required for "%s.',
260
                    $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

260
                    /** @scrutinizer ignore-type */ $this->file
Loading history...
261
                );
262
            }
263
        }
264
265
        if ($info['flags'] & self::BZ2) {
266
            if (!function_exists('bzdecompress')) {
267
                throw new RuntimeException(
268
                    'The bzip2 extension (bzdecompress()) is required for "%s".',
269
                    $this->file
270
                );
271
            }
272
        }
273
274
        self::purge($dir);
0 ignored issues
show
Bug introduced by
It seems like $dir can also be of type false; however, parameter $path of KevinGH\Box\Extract::purge() does only seem to accept string, 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

274
        self::purge(/** @scrutinizer ignore-type */ $dir);
Loading history...
275
        $this->createDir($dir);
276
        $this->createFile($md5);
277
278
        foreach ($info['files'] as $info) {
279
            $path = $dir.DIRECTORY_SEPARATOR.$info['path'];
280
            $parent = dirname($path);
281
282
            if (!is_dir($parent)) {
283
                $this->createDir($parent);
284
            }
285
286
            if (preg_match('{/$}', $info['path'])) {
287
                $this->createDir($path, 0777, false);
288
            } else {
289
                $this->createFile(
290
                    $path,
291
                    $this->extractFile($info)
292
                );
293
            }
294
        }
295
296
        return $dir;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $dir could also return false which is incompatible with the documented return type string. Did you maybe forget to handle an error condition?

If the returned type also contains false, it is an indicator that maybe an error condition leading to the specific return statement remains unhandled.

Loading history...
297
    }
298
299
    /**
300
     * Recursively deletes the directory or file path.
301
     *
302
     * @param string $path the path to delete
303
     *
304
     * @throws RuntimeException if the path could not be deleted
305
     */
306
    public static function purge($path): void
307
    {
308
        if (is_dir($path)) {
309
            foreach (scandir($path) as $item) {
310
                if (('.' === $item) || ('..' === $item)) {
311
                    continue;
312
                }
313
314
                self::purge($path.DIRECTORY_SEPARATOR.$item);
315
            }
316
317
            if (!rmdir($path)) {
318
                throw new RuntimeException(
319
                    sprintf(
320
                        'The directory "%s" could not be deleted.',
321
                        $path
322
                    )
323
                );
324
            }
325
        } else {
326
            if (!unlink($path)) {
327
                throw new RuntimeException(
328
                    sprintf(
329
                        'The file "%s" could not be deleted.',
330
                        $path
331
                    )
332
                );
333
            }
334
        }
335
    }
336
337
    /**
338
     * Creates a new directory.
339
     *
340
     * @param string $path      the directory path
341
     * @param int    $chmod     the file mode
342
     * @param bool   $recursive Recursively create path?
343
     *
344
     * @throws RuntimeException if the path could not be created
345
     */
346
    private function createDir($path, $chmod = 0777, $recursive = true): void
347
    {
348
        if (!mkdir($path, $chmod, $recursive)) {
349
            throw new RuntimeException(
350
                sprintf(
351
                    'The directory path "%s" could not be created.',
352
                    $path
353
                )
354
            );
355
        }
356
    }
357
358
    /**
359
     * Creates a new file.
360
     *
361
     * @param string $path     the file path
362
     * @param string $contents the file contents
363
     * @param int    $mode     the file mode
364
     *
365
     * @throws RuntimeException if the file could not be created
366
     */
367
    private function createFile($path, $contents = '', $mode = 0666): void
368
    {
369
        if (false === file_put_contents($path, $contents)) {
370
            throw new RuntimeException(
371
                sprintf(
372
                    'The file "%s" could not be written.',
373
                    $path
374
                )
375
            );
376
        }
377
378
        if (!chmod($path, $mode)) {
379
            throw new RuntimeException(
380
                sprintf(
381
                    'The file "%s" could not be chmodded to %o.',
382
                    $path,
383
                    $mode
384
                )
385
            );
386
        }
387
    }
388
389
    /**
390
     * Extracts a single file from the phar.
391
     *
392
     * @param array $info the file information
393
     *
394
     * @throws RuntimeException         if the file could not be extracted
395
     * @throws UnexpectedValueException if the crc32 checksum does not
396
     *                                  match the expected value
397
     *
398
     * @return string the file data
399
     */
400
    private function extractFile($info)
401
    {
402
        if (0 === $info['size']) {
403
            return '';
404
        }
405
406
        $data = $this->read($info['compressed_size']);
407
408
        if ($info['flags'] & self::GZ) {
409
            if (false === ($data = gzinflate($data))) {
410
                throw new RuntimeException(
411
                    sprintf(
412
                        'The "%s" file could not be inflated (gzip) from "%s".',
413
                        $info['path'],
414
                        $this->file
415
                    )
416
                );
417
            }
418
        } elseif ($info['flags'] & self::BZ2) {
419
            if (false === ($data = bzdecompress($data))) {
420
                throw new RuntimeException(
421
                    sprintf(
422
                        'The "%s" file could not be inflated (bzip2) from "%s".',
423
                        $info['path'],
424
                        $this->file
425
                    )
426
                );
427
            }
428
        }
429
430
        if (($actual = strlen($data)) !== $info['size']) {
431
            throw new UnexpectedValueException(
432
                sprintf(
433
                    'The size of "%s" (%d) did not match what was expected (%d) in "%s".',
434
                    $info['path'],
435
                    $actual,
436
                    $info['size'],
437
                    $this->file
438
                )
439
            );
440
        }
441
442
        $crc32 = sprintf('%u', crc32($data) & 0xffffffff);
443
444
        if ($info['crc32'] != $crc32) {
445
            throw new UnexpectedValueException(
446
                sprintf(
447
                    'The crc32 checksum (%s) for "%s" did not match what was expected (%s) in "%s".',
448
                    $crc32,
449
                    $info['path'],
450
                    $info['crc32'],
451
                    $this->file
452
                )
453
            );
454
        }
455
456
        return $data;
457
    }
458
459
    /**
460
     * Opens the file for reading.
461
     *
462
     * @throws RuntimeException if the file could not be opened
463
     */
464
    private function open(): void
465
    {
466
        if (null === ($this->handle = fopen($this->file, 'rb'))) {
0 ignored issues
show
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...
467
            $this->handle = null;
468
469
            throw new RuntimeException(
470
                sprintf(
471
                    'The file "%s" could not be opened for reading.',
472
                    $this->file
473
                )
474
            );
475
        }
476
    }
477
478
    /**
479
     * Reads the number of bytes from the file.
480
     *
481
     * @param int $bytes the number of bytes
482
     *
483
     * @throws RuntimeException if the read fails
484
     *
485
     * @return string the binary string read
486
     */
487
    private function read($bytes)
488
    {
489
        $read = '';
490
        $total = $bytes;
491
492
        while (!feof($this->handle) && $bytes) {
493
            if (false === ($chunk = fread($this->handle, $bytes))) {
494
                throw new RuntimeException(
495
                    sprintf(
496
                        'Could not read %d bytes from "%s".',
497
                        $bytes,
498
                        $this->file
499
                    )
500
                );
501
            }
502
503
            $read .= $chunk;
504
            $bytes -= strlen($chunk);
505
        }
506
507
        if (($actual = strlen($read)) !== $total) {
508
            throw new RuntimeException(
509
                sprintf(
510
                    'Only read %d of %d in "%s".',
511
                    $actual,
512
                    $total,
513
                    $this->file
514
                )
515
            );
516
        }
517
518
        return $read;
519
    }
520
521
    /**
522
     * Reads and unpacks the manifest data from the phar.
523
     *
524
     * @return array the manifest
525
     */
526
    private function readManifest()
527
    {
528
        $size = unpack('V', $this->read(4));
0 ignored issues
show
Bug introduced by
The call to unpack() has too few arguments starting with offset. ( Ignorable by Annotation )

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

528
        $size = /** @scrutinizer ignore-call */ unpack('V', $this->read(4));

This check compares calls to functions or methods with their respective definitions. If the call has less arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
529
        $size = $size[1];
530
531
        $raw = $this->read($size);
532
533
        // ++ start skip: API version, global flags, alias, and metadata
534
        $count = unpack('V', substr($raw, 0, 4));
0 ignored issues
show
Bug introduced by
It seems like substr($raw, 0, 4) can also be of type false; however, parameter $data of unpack() does only seem to accept string, 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

534
        $count = unpack('V', /** @scrutinizer ignore-type */ substr($raw, 0, 4));
Loading history...
535
        $count = $count[1];
536
537
        $aliasSize = unpack('V', substr($raw, 10, 4));
538
        $aliasSize = $aliasSize[1];
539
        $raw = substr($raw, 14 + $aliasSize);
540
541
        $metaSize = unpack('V', substr($raw, 0, 4));
0 ignored issues
show
Bug introduced by
It seems like $raw can also be of type false; however, parameter $string of substr() does only seem to accept string, 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

541
        $metaSize = unpack('V', substr(/** @scrutinizer ignore-type */ $raw, 0, 4));
Loading history...
542
        $metaSize = $metaSize[1];
543
544
        $offset = 0;
545
        $start = 4 + $metaSize;
546
        // -- end skip
547
548
        $manifest = [
549
            'files' => [],
550
            'flags' => 0,
551
        ];
552
553
        for ($i = 0; $i < $count; $i++) {
554
            $length = unpack('V', substr($raw, $start, 4));
555
            $length = $length[1];
556
            $start += 4;
557
558
            $path = substr($raw, $start, $length);
559
            $start += $length;
560
561
            $file = unpack(
562
                'Vsize/Vtimestamp/Vcompressed_size/Vcrc32/Vflags/Vmetadata_length',
563
                substr($raw, $start, 24)
564
            );
565
566
            $file['path'] = $path;
567
            $file['crc32'] = sprintf('%u', $file['crc32'] & 0xffffffff);
568
            $file['offset'] = $offset;
569
570
            $offset += $file['compressed_size'];
571
            $start += 24 + $file['metadata_length'];
572
573
            $manifest['flags'] |= $file['flags'] & self::MASK;
574
575
            $manifest['files'][] = $file;
576
        }
577
578
        return $manifest;
579
    }
580
}
581