Passed
Pull Request — master (#5)
by Wilmer
02:20
created

FileHelper::normalizePath()   C

Complexity

Conditions 14
Paths 36

Size

Total Lines 29
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 14.0285

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 14
eloc 18
c 1
b 0
f 0
nc 36
nop 1
dl 0
loc 29
ccs 18
cts 19
cp 0.9474
crap 14.0285
rs 6.2666

How to fix   Complexity   

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
declare(strict_types = 1);
3
4
namespace Yiisoft\Files;
5
6
use Yiisoft\Strings\StringHelper;
7
8
/**
9
 * FileHelper provides useful methods to manage files and directories
10
 */
11
class FileHelper
12
{
13
    private const PATTERN_NODIR = 1;
14
    private const PATTERN_ENDSWITH = 4;
15
    private const PATTERN_MUSTBEDIR = 8;
16
    private const PATTERN_NEGATIVE = 16;
17
    private const PATTERN_CASE_INSENSITIVE = 32;
18
19
    /**
20
     * Normalizes a file/directory path.
21
     *
22
     * The normalization does the following work:
23
     *
24
     * - Convert all directory separators into `/` (e.g. "\a/b\c" becomes "/a/b/c")
25
     * - Remove trailing directory separators (e.g. "/a/b/c/" becomes "/a/b/c")
26
     * - Turn multiple consecutive slashes into a single one (e.g. "/a///b/c" becomes "/a/b/c")
27
     * - Remove ".." and "." based on their meanings (e.g. "/a/./b/../c" becomes "/a/c")
28
     *
29
     * @param string $path the file/directory path to be normalized
30
     *
31
     * @return string the normalized file/directory path
32
     */
33 3
    public static function normalizePath(string $path): string
34
    {
35 3
        $isWindowsShare = strpos($path, '\\\\') === 0;
36 3
        if ($isWindowsShare) {
37 1
            $path = substr($path, 2);
38
        }
39
40 3
        $path = rtrim(strtr($path, '/\\', '//'), '/');
41 3
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
42 3
            if ($isWindowsShare) {
43 1
                $path = $path = '\\\\' . $path;
0 ignored issues
show
Unused Code introduced by
The assignment to $path is dead and can be removed.
Loading history...
44
            }
45 3
            return $path;
46
        }
47
48 1
        $parts = [];
49
50 1
        foreach (explode('/', $path) as $part) {
51 1
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
52 1
                array_pop($parts);
53 1
            } elseif ($part !== '.' && ($part !== '' || empty($parts))) {
54 1
                $parts[] = $part;
55
            }
56
        }
57 1
        $path = implode('/', $parts);
58 1
        if ($isWindowsShare) {
59
            $path = '\\\\' . $path;
60
        }
61 1
        return $path === '' ? '.' : $path;
62
    }
63
64
    /**
65
     * Removes a directory (and all its content) recursively.
66
     *
67
     * @param string $directory the directory to be deleted recursively.
68
     * @param array $options options for directory remove. Valid options are:
69
     *
70
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
71
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
72
     *   Only symlink would be removed in that default case.
73
     *
74
     * @return void
75
     */
76 8
    public static function removeDirectory(string $directory, array $options = []): void
77
    {
78 8
        if (!is_dir($directory)) {
79 1
            return;
80
        }
81 8
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
82 8
            if (!($handle = opendir($directory))) {
83
                return;
84
            }
85 8
            while (($file = readdir($handle)) !== false) {
86 8
                if ($file === '.' || $file === '..') {
87 8
                    continue;
88
                }
89 7
                $path = $directory . '/' . $file;
90 7
                if (is_dir($path)) {
91 7
                    self::removeDirectory($path, $options);
92
                } else {
93 3
                    self::unlink($path);
94
                }
95
            }
96 8
            closedir($handle);
97
        }
98 8
        if (is_link($directory)) {
99 2
            self::unlink($directory);
100
        } else {
101 8
            rmdir($directory);
102
        }
103 8
    }
104
105
    /**
106
     * Removes a file or symlink in a cross-platform way.
107
     *
108
     * @param string $path
109
     *
110
     * @return bool
111
     */
112 3
    public static function unlink(string $path): bool
113
    {
114 3
        $isWindows = DIRECTORY_SEPARATOR === '\\';
115
116 3
        if (!$isWindows) {
117 3
            return unlink($path);
118
        }
119
120
        if (is_link($path) && is_dir($path)) {
121
            return rmdir($path);
122
        }
123
124
        return unlink($path);
125
    }
126
127
    /**
128
     * Creates a new directory.
129
     *
130
     * This method is similar to the PHP `mkdir()` function except that it uses `chmod()` to set the permission of the
131
     * created directory in order to avoid the impact of the `umask` setting.
132
     *
133
     * @param string $path path of the directory to be created.
134
     * @param int $mode the permission to be set for the created directory.
135
     *
136
     * @return bool whether the directory is created successfully.
137
     */
138 8
    public static function createDirectory(string $path, int $mode = 0775): bool
139
    {
140 8
        if (is_dir($path)) {
141 1
            return true;
142
        }
143
        try {
144 8
            if (!mkdir($path, $mode, true) && !is_dir($path)) {
145 8
                return false;
146
            }
147
        } catch (\Exception $e) {
148
            if (!is_dir($path)) { // https://github.com/yiisoft/yii2/issues/9288
149
                throw new \RuntimeException("Failed to create directory \"$path\": " . $e->getMessage(), $e->getCode(), $e);
150
            }
151
        }
152
153
        try {
154 8
            return chmod($path, $mode);
155
        } catch (\Exception $e) {
156
            throw new \RuntimeException("Failed to change permissions for directory \"$path\": " . $e->getMessage(), $e->getCode(), $e);
157
        }
158
    }
159
160
    /**
161
     * Copies a whole directory as another one.
162
     *
163
     * The files and sub-directories will also be copied over.
164
     *
165
     * @param string $source the source directory.
166
     * @param string $destination the destination directory.
167
     * @param array $options options for directory copy. Valid options are:
168
     *
169
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
170
     * - fileMode:  integer, the permission to be set for newly copied files. Defaults to the current environment
171
     *   setting.
172
     * - filter: callback, a PHP callback that is called for each directory or file.
173
     *   The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered.
174
     *   The callback can return one of the following values:
175
     *
176
     *   * true: the directory or file will be copied (the "only" and "except" options will be ignored).
177
     *   * false: the directory or file will NOT be copied (the "only" and "except" options will be ignored).
178
     *   * null: the "only" and "except" options will determine whether the directory or file should be copied.
179
     *
180
     * - only: array, list of patterns that the file paths should match if they want to be copied. A path matches a
181
     *   pattern if it contains the pattern string at its end. For example, '.php' matches all file paths ending with
182
     *   '.php'.
183
     *   Note, the '/' characters in a pattern matches both '/' and '\' in the paths. If a file path matches a pattern
184
     *   in both "only" and "except", it will NOT be copied.
185
     * - except: array, list of patterns that the files or directories should match if they want to be excluded from
186
     *   being copied. A path matches a pattern if it contains the pattern string at its end. Patterns ending with '/'
187
     *   apply to directory paths only, and patterns not ending with '/' apply to file paths only. For example, '/a/b'
188
     *   matches all file paths ending with '/a/b'; and '.svn/' matches directory paths ending with '.svn'. Note, the
189
     *   '/' characters in a pattern matches both '/' and '\' in the paths.
190
     * - caseSensitive: boolean, whether patterns specified at "only" or "except" should be case sensitive. Defaults to
191
     *   true.
192
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
193
     * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file. If the callback
194
     *   returns false, the copy operation for the sub-directory or file will be cancelled. The signature of the
195
     *   callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file to be copied from,
196
     *   while `$to` is the copy target.
197
     * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied.
198
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file
199
     *   copied from, while `$to` is the copy target.
200
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating
201
     *   directories that do not contain files. This affects directories that do not contain files initially as well as
202
     *   directories that do not contain files at the target destination because files have been filtered via `only` or
203
     *   `except`. Defaults to true.
204
     *
205
     * @throws \InvalidArgumentException if unable to open directory
206
     * @throws \Exception
207
     *
208
     * @return void
209
     */
210 2
    public static function copyDirectory(string $source, string $destination, array $options = []): void
211
    {
212 2
        $source = static::normalizePath($source);
213 2
        $destination = static::normalizePath($destination);
214
215 2
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
216 2
            throw new \InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
217
        }
218
219
        $destinationExists = is_dir($destination);
220
221
        if (!$destinationExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
222
            static::createDirectory($destination, $options['dirMode'] ?? 0775);
223
            $destinationExists = true;
224
        }
225
226
        $handle = opendir($source);
227
228
        if ($handle === false) {
229
            throw new \InvalidArgumentException("Unable to open directory: $source");
230
        }
231
232
        if (!isset($options['basePath'])) {
233
            // this should be done only once
234
            $options['basePath'] = realpath($source);
235
            $options = static::normalizeOptions($options);
236
        }
237
238
        while (($file = readdir($handle)) !== false) {
239
            if ($file === '.' || $file === '..') {
240
                continue;
241
            }
242
            $from = $source . '/' . $file;
243
            $to = $destination . '/' . $file;
244
            if (static::filterPath($from, $options)) {
245
                if (isset($options['beforeCopy']) && !\call_user_func($options['beforeCopy'], $from, $to)) {
246
                    continue;
247
                }
248
                if (is_file($from)) {
249
                    if (!$destinationExists) {
250
                        // delay creation of destination directory until the first file is copied to avoid creating empty directories
251
                        static::createDirectory($destination, $options['dirMode'] ?? 0775);
252
                        $destinationExists = true;
253
                    }
254
                    copy($from, $to);
255
                    if (isset($options['fileMode'])) {
256
                        @chmod($to, $options['fileMode']);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for chmod(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

256
                        /** @scrutinizer ignore-unhandled */ @chmod($to, $options['fileMode']);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
257
                    }
258
                } elseif (!isset($options['recursive']) || $options['recursive']) {
259
                    // recursive copy, defaults to true
260
                    static::copyDirectory($from, $to, $options);
261
                }
262
                if (isset($options['afterCopy'])) {
263
                    \call_user_func($options['afterCopy'], $from, $to);
264
                }
265
            }
266
        }
267
268
        closedir($handle);
269
    }
270
271
    /**
272
     * Normalize options.
273
     *
274
     * @param array $options raw options.
275
     *
276
     * @return array normalized options.
277
     */
278
    protected static function normalizeOptions(array $options): array
279
    {
280
        if (!array_key_exists('caseSensitive', $options)) {
281
            $options['caseSensitive'] = true;
282
        }
283
284
        if (isset($options['except'])) {
285
            foreach ($options['except'] as $key => $value) {
286
                if (\is_string($value)) {
287
                    $options['except'][$key] = self::parseExcludePattern($value, $options['caseSensitive']);
288
                }
289
            }
290
        }
291
292
        if (isset($options['only'])) {
293
            foreach ($options['only'] as $key => $value) {
294
                if (\is_string($value)) {
295
                    $options['only'][$key] = self::parseExcludePattern($value, $options['caseSensitive']);
296
                }
297
            }
298
        }
299
300
        return $options;
301
    }
302
303
    /**
304
     * Checks if the given file path satisfies the filtering options.
305
     *
306
     * @param string $path the path of the file or directory to be checked.
307
     * @param array $options the filtering options.
308
     *
309
     * @return bool whether the file or directory satisfies the filtering options.
310
     */
311
    public static function filterPath(string $path, array $options): bool
312
    {
313
        if (isset($options['filter'])) {
314
            $result = \call_user_func($options['filter'], $path);
315
            if (\is_bool($result)) {
316
                return $result;
317
            }
318
        }
319
320
        if (empty($options['except']) && empty($options['only'])) {
321
            return true;
322
        }
323
324
        $path = str_replace('\\', '/', $path);
325
326
        if (!empty($options['except'])) {
327
            $except = self::lastExcludeMatchingFromList($options['basePath'], $path, $options['except']);
328
            if ($except !== null) {
329
                return $except['flags'] & self::PATTERN_NEGATIVE;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $except['flags'] & self::PATTERN_NEGATIVE returns the type integer which is incompatible with the type-hinted return boolean.
Loading history...
330
            }
331
        }
332
333
        if (!empty($options['only']) && !is_dir($path)) {
334
            // don't check PATTERN_NEGATIVE since those entries are not prefixed with !
335
            return self::lastExcludeMatchingFromList($options['basePath'], $path, $options['only']) !== null;
336
        }
337
338
        return true;
339
    }
340
341
    /**
342
     * Searches for the first wildcard character in the pattern.
343
     *
344
     * @param string $pattern the pattern to search in.
345
     *
346
     * @return int|bool position of first wildcard character or false if not found.
347
     */
348
    private static function firstWildcardInPattern($pattern)
349
    {
350
        $wildcards = ['*', '?', '[', '\\'];
351
        $wildcardSearch = function ($carry, $item) use ($pattern) {
352
            $position = strpos($pattern, $item);
353
            if ($position === false) {
354
                return $carry === false ? $position : $carry;
355
            }
356
            return $carry === false ? $position : min($carry, $position);
357
        };
358
        return array_reduce($wildcards, $wildcardSearch, false);
359
    }
360
361
362
    /**
363
     * Scan the given exclude list in reverse to see whether pathname should be ignored.
364
     *
365
     * The first match (i.e. the last on the list), if any, determines the fate.  Returns the element which matched,
366
     * or null for undecided.
367
     *
368
     * Based on last_exclude_matching_from_list() from dir.c of git 1.8.5.3 sources.
369
     *
370
     * @param string $basePath.
371
     * @param string $path.
372
     * @param array $excludes list of patterns to match $path against.
373
     *
374
     * @return null|array null or one of $excludes item as an array with keys: 'pattern', 'flags'.
375
     *
376
     * @throws \InvalidArgumentException if any of the exclude patterns is not a string or an array with keys: pattern,
377
     *                                   flags, firstWildcard.
378
     */
379
    private static function lastExcludeMatchingFromList(string $basePath, string $path, array $excludes): ?array
380
    {
381
        foreach (array_reverse($excludes) as $exclude) {
382
            if (\is_string($exclude)) {
383
                $exclude = self::parseExcludePattern($exclude, false);
384
            }
385
386
            if (!isset($exclude['pattern'], $exclude['flags'], $exclude['firstWildcard'])) {
387
                throw new \InvalidArgumentException('If exclude/include pattern is an array it must contain the pattern, flags and firstWildcard keys.');
388
            }
389
390
            if (($exclude['flags'] & self::PATTERN_MUSTBEDIR) && !is_dir($path)) {
391
                continue;
392
            }
393
394
            if ($exclude['flags'] & self::PATTERN_NODIR) {
395
                if (self::matchBasename(basename($path), $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) {
396
                    return $exclude;
397
                }
398
                continue;
399
            }
400
401
            if (self::matchPathname($path, $basePath, $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) {
402
                return $exclude;
403
            }
404
        }
405
406
        return null;
407
    }
408
409
    /**
410
     * Performs a simple comparison of file or directory names.
411
     *
412
     * Based on match_basename() from dir.c of git 1.8.5.3 sources.
413
     *
414
     * @param string $baseName file or directory name to compare with the pattern.
415
     * @param string $pattern the pattern that $baseName will be compared against.
416
     * @param int|bool $firstWildcard location of first wildcard character in the $pattern.
417
     * @param int $flags pattern flags
418
     *
419
     * @return bool whether the name matches against pattern
420
     */
421
    private static function matchBasename(string $baseName, string $pattern, ?bool $firstWildcard, int $flags): bool
422
    {
423
        if ($firstWildcard === false) {
424
            if ($pattern === $baseName) {
425
                return true;
426
            }
427
        } elseif ($flags & self::PATTERN_ENDSWITH) {
428
            /* "*literal" matching against "fooliteral" */
429
            $n = StringHelper::byteLength($pattern);
430
            if (StringHelper::byteSubstr($pattern, 1, $n) === StringHelper::byteSubstr($baseName, -$n, $n)) {
431
                return true;
432
            }
433
        }
434
435
        $matchOptions = [];
436
437
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
438
            $matchOptions['caseSensitive'] = false;
439
        }
440
441
        return StringHelper::matchWildcard($pattern, $baseName, $matchOptions);
442
    }
443
444
    /**
445
     * Compares a path part against a pattern with optional wildcards.
446
     *
447
     * Based on match_pathname() from dir.c of git 1.8.5.3 sources.
448
     *
449
     * @param string $path full path to compare
450
     * @param string $basePath base of path that will not be compared
451
     * @param string $pattern the pattern that path part will be compared against
452
     * @param int|bool $firstWildcard location of first wildcard character in the $pattern
453
     * @param int $flags pattern flags
454
     *
455
     * @return bool whether the path part matches against pattern
456
     */
457
    private static function matchPathname(string $path, string $basePath, string $pattern, ?bool $firstWildcard, int $flags): bool
458
    {
459
        // match with FNM_PATHNAME; the pattern has base implicitly in front of it.
460
        if (strpos($pattern, '/') === 0) {
461
            $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern));
462
            if ($firstWildcard !== false && $firstWildcard !== 0) {
463
                $firstWildcard--;
464
            }
465
        }
466
467
        $namelen = StringHelper::byteLength($path) - (empty($basePath) ? 0 : StringHelper::byteLength($basePath) + 1);
468
        $name = StringHelper::byteSubstr($path, -$namelen, $namelen);
469
470
        if ($firstWildcard !== 0) {
0 ignored issues
show
introduced by
The condition $firstWildcard !== 0 is always true.
Loading history...
471
            if ($firstWildcard === false) {
472
                $firstWildcard = StringHelper::byteLength($pattern);
473
            }
474
475
            // if the non-wildcard part is longer than the remaining pathname, surely it cannot match.
476
            if ($firstWildcard > $namelen) {
477
                return false;
478
            }
479
480
            if (strncmp($pattern, $name, $firstWildcard)) {
0 ignored issues
show
Bug introduced by
It seems like $firstWildcard can also be of type true; however, parameter $len of strncmp() does only seem to accept integer, 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

480
            if (strncmp($pattern, $name, /** @scrutinizer ignore-type */ $firstWildcard)) {
Loading history...
481
                return false;
482
            }
483
484
            $pattern = StringHelper::byteSubstr($pattern, $firstWildcard, StringHelper::byteLength($pattern));
0 ignored issues
show
Bug introduced by
It seems like $firstWildcard can also be of type true; however, parameter $start of Yiisoft\Strings\StringHelper::byteSubstr() does only seem to accept integer, 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

484
            $pattern = StringHelper::byteSubstr($pattern, /** @scrutinizer ignore-type */ $firstWildcard, StringHelper::byteLength($pattern));
Loading history...
485
            $name = StringHelper::byteSubstr($name, $firstWildcard, $namelen);
486
487
            // If the whole pattern did not have a wildcard, then our prefix match is all we need; we do not need to call fnmatch at all.
488
            if (empty($pattern) && empty($name)) {
489
                return true;
490
            }
491
        }
492
493
        $matchOptions = [
494
            'filePath' => true
495
        ];
496
497
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
498
            $matchOptions['caseSensitive'] = false;
499
        }
500
501
        return StringHelper::matchWildcard($pattern, $name, $matchOptions);
502
    }
503
504
    /**
505
     * Processes the pattern, stripping special characters like / and ! from the beginning and settings flags instead.
506
     *
507
     * @param string $pattern
508
     * @param bool $caseSensitive
509
     *
510
     * @throws \InvalidArgumentException
511
     *
512
     * @return array with keys: (string) pattern, (int) flags, (int|bool) firstWildcard
513
     */
514
    private static function parseExcludePattern(string $pattern, bool $caseSensitive): array
515
    {
516
        if (!\is_string($pattern)) {
0 ignored issues
show
introduced by
The condition is_string($pattern) is always true.
Loading history...
517
            throw new \InvalidArgumentException('Exclude/include pattern must be a string.');
518
        }
519
520
        $result = [
521
            'pattern' => $pattern,
522
            'flags' => 0,
523
            'firstWildcard' => false,
524
        ];
525
526
        if (!$caseSensitive) {
527
            $result['flags'] |= self::PATTERN_CASE_INSENSITIVE;
528
        }
529
530
        if (!isset($pattern[0])) {
531
            return $result;
532
        }
533
534
        if (strpos($pattern, '!') === 0) {
535
            $result['flags'] |= self::PATTERN_NEGATIVE;
536
            $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern));
537
        }
538
539
        if (StringHelper::byteLength($pattern) && StringHelper::byteSubstr($pattern, -1, 1) === '/') {
540
            $pattern = StringHelper::byteSubstr($pattern, 0, -1);
541
            $result['flags'] |= self::PATTERN_MUSTBEDIR;
542
        }
543
544
        if (strpos($pattern, '/') === false) {
545
            $result['flags'] |= self::PATTERN_NODIR;
546
        }
547
548
        $result['firstWildcard'] = self::firstWildcardInPattern($pattern);
549
550
        if (strpos($pattern, '*') === 0 && self::firstWildcardInPattern(StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern))) === false) {
551
            $result['flags'] |= self::PATTERN_ENDSWITH;
552
        }
553
554
        $result['pattern'] = $pattern;
555
        
556
        return $result;
557
    }
558
}
559