Passed
Pull Request — master (#5)
by Wilmer
01:14
created

FileHelper::matchPathname()   C

Complexity

Conditions 12
Paths 72

Size

Total Lines 45
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 12.8652

Importance

Changes 0
Metric Value
cc 12
eloc 22
nc 72
nop 5
dl 0
loc 45
ccs 18
cts 22
cp 0.8182
crap 12.8652
rs 6.9666
c 0
b 0
f 0

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_NO_DIR = 1;
14
    private const PATTERN_ENDS_WITH = 4;
15
    private const PATTERN_MUST_BE_DIR = 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 17
    public static function normalizePath(string $path): string
34
    {
35 17
        $isWindowsShare = strpos($path, '\\\\') === 0;
36 17
        if ($isWindowsShare) {
37 1
            $path = substr($path, 2);
38
        }
39
40 17
        $path = rtrim(strtr($path, '/\\', '//'), '/');
41 17
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
42 17
            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 17
            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 17
    public static function removeDirectory(string $directory, array $options = []): void
77
    {
78 17
        if (!is_dir($directory)) {
79 1
            return;
80
        }
81 17
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
82 17
            if (!($handle = opendir($directory))) {
83
                return;
84
            }
85 17
            while (($file = readdir($handle)) !== false) {
86 17
                if ($file === '.' || $file === '..') {
87 17
                    continue;
88
                }
89 16
                $path = $directory . '/' . $file;
90 16
                if (is_dir($path)) {
91 16
                    self::removeDirectory($path, $options);
92
                } else {
93 10
                    self::unlink($path);
94
                }
95
            }
96 17
            closedir($handle);
97
        }
98 17
        if (is_link($directory)) {
99 2
            self::unlink($directory);
100
        } else {
101 17
            rmdir($directory);
102
        }
103 17
    }
104
105
    /**
106
     * Removes a file or symlink in a cross-platform way.
107
     *
108
     * @param string $path
109
     *
110
     * @return bool
111
     */
112 10
    public static function unlink(string $path): bool
113
    {
114 10
        $isWindows = DIRECTORY_SEPARATOR === '\\';
115
116 10
        if (!$isWindows) {
117 10
            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 17
    public static function createDirectory(string $path, int $mode = 0775): bool
139
    {
140 17
        if (is_dir($path)) {
141 1
            return true;
142
        }
143
        try {
144 17
            if (!mkdir($path, $mode, true) && !is_dir($path)) {
145 17
                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 17
            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 11
    public static function copyDirectory(string $source, string $destination, array $options = []): void
211
    {
212 11
        $source = static::normalizePath($source);
213 11
        $destination = static::normalizePath($destination);
214
215 11
        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 9
        $destinationExists = is_dir($destination);
220
221 9
        if (!$destinationExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
222 5
            static::createDirectory($destination, $options['dirMode'] ?? 0775);
223 5
            $destinationExists = true;
224
        }
225
226 9
        $handle = opendir($source);
227
228 9
        if ($handle === false) {
229
            throw new \InvalidArgumentException("Unable to open directory: $source");
230
        }
231
232 9
        if (!isset($options['basePath'])) {
233
            // this should be done only once
234 9
            $options['basePath'] = realpath($source);
235 9
            $options = static::normalizeOptions($options);
236
        }
237
238 9
        while (($file = readdir($handle)) !== false) {
239 9
            if ($file === '.' || $file === '..') {
240 9
                continue;
241
            }
242
243 7
            $from = $source . '/' . $file;
244 7
            $to = $destination . '/' . $file;
245
246 7
            if ($status = static::filterPath($from, $options)) {
0 ignored issues
show
Unused Code introduced by
The assignment to $status is dead and can be removed.
Loading history...
247 7
                if (isset($options['beforeCopy']) && !\call_user_func($options['beforeCopy'], $from, $to)) {
248
                    continue;
249
                }
250 7
                if (is_file($from)) {
251 7
                    if (!$destinationExists) {
252
                        // delay creation of destination directory until the first file is copied to avoid creating empty directories
253 2
                        static::createDirectory($destination, $options['dirMode'] ?? 0775);
254 2
                        $destinationExists = true;
255
                    }
256 7
                    copy($from, $to);
257 7
                    if (isset($options['fileMode'])) {
258 7
                        @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

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

470
            if (strncmp($pattern, $name, /** @scrutinizer ignore-type */ $firstWildcard)) {
Loading history...
471 2
                return false;
472
            }
473
474 2
            $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

474
            $pattern = StringHelper::byteSubstr($pattern, /** @scrutinizer ignore-type */ $firstWildcard, StringHelper::byteLength($pattern));
Loading history...
475 2
            $name = StringHelper::byteSubstr($name, $firstWildcard, $namelen);
476
477
            // 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.
478 2
            if (empty($pattern) && empty($name)) {
479 1
                return true;
480
            }
481
        }
482
483
        $matchOptions = [
484 2
            'filePath' => true
485
        ];
486
487 2
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
488
            $matchOptions['caseSensitive'] = false;
489
        }
490
491 2
        return StringHelper::matchWildcard($pattern, $name, $matchOptions);
492
    }
493
494
    /**
495
     * Processes the pattern, stripping special characters like / and ! from the beginning and settings flags instead.
496
     *
497
     * @param string $pattern
498
     * @param bool $caseSensitive
499
     *
500
     * @return array with keys: (string) pattern, (int) flags, (int|bool) firstWildcard
501
     */
502 2
    private static function parseExcludePattern(string $pattern, bool $caseSensitive): array
503
    {
504
        $result = [
505 2
            'pattern' => $pattern,
506 2
            'flags' => 0,
507
            'firstWildcard' => false,
508
        ];
509
510 2
        if (!$caseSensitive) {
511
            $result['flags'] |= self::PATTERN_CASE_INSENSITIVE;
512
        }
513
514 2
        if (!isset($pattern[0])) {
515
            return $result;
516
        }
517
518 2
        if (strpos($pattern, '!') === 0) {
519
            $result['flags'] |= self::PATTERN_NEGATIVE;
520
            $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern));
521
        }
522
523 2
        if (StringHelper::byteLength($pattern) && StringHelper::byteSubstr($pattern, -1, 1) === '/') {
524
            $pattern = StringHelper::byteSubstr($pattern, 0, -1);
525
            $result['flags'] |= self::PATTERN_MUST_BE_DIR;
526
        }
527
528 2
        if (strpos($pattern, '/') === false) {
529
            $result['flags'] |= self::PATTERN_NO_DIR;
530
        }
531
532 2
        $result['firstWildcard'] = self::firstWildcardInPattern($pattern);
533
534 2
        if (strpos($pattern, '*') === 0 && self::firstWildcardInPattern(StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern))) === false) {
535
            $result['flags'] |= self::PATTERN_ENDS_WITH;
536
        }
537
538 2
        $result['pattern'] = $pattern;
539
540 2
        return $result;
541
    }
542
}
543