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

FileHelper::matchPathname()   C

Complexity

Conditions 12
Paths 72

Size

Total Lines 45
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 156

Importance

Changes 0
Metric Value
cc 12
eloc 22
nc 72
nop 5
dl 0
loc 45
ccs 0
cts 22
cp 0
crap 156
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
    public static function normalizePath(string $path): string
34
    {
35
        $isWindowsShare = strpos($path, '\\\\') === 0;
36
        if ($isWindowsShare) {
37
            $path = substr($path, 2);
38
        }
39
40
        $path = rtrim(strtr($path, '/\\', '//'), '/');
41
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
42
            if ($isWindowsShare) {
43
                $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
            return $path;
46
        }
47
48
        $parts = [];
49
50
        foreach (explode('/', $path) as $part) {
51
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
52
                array_pop($parts);
53
            } elseif ($part !== '.' && ($part !== '' || empty($parts))) {
54
                $parts[] = $part;
55
            }
56
        }
57
        $path = implode('/', $parts);
58
        if ($isWindowsShare) {
59
            $path = '\\\\' . $path;
60
        }
61
        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
    public static function removeDirectory(string $directory, array $options = []): void
77
    {
78
        if (!is_dir($directory)) {
79
            return;
80
        }
81
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
82
            if (!($handle = opendir($directory))) {
83
                return;
84
            }
85
            while (($file = readdir($handle)) !== false) {
86
                if ($file === '.' || $file === '..') {
87
                    continue;
88
                }
89
                $path = $directory . '/' . $file;
90
                if (is_dir($path)) {
91
                    self::removeDirectory($path, $options);
92
                } else {
93
                    self::unlink($path);
94
                }
95
            }
96
            closedir($handle);
97
        }
98
        if (is_link($directory)) {
99
            self::unlink($directory);
100
        } else {
101
            rmdir($directory);
102
        }
103
    }
104
105
    /**
106
     * Removes a file or symlink in a cross-platform way.
107
     *
108
     * @param string $path
109
     *
110
     * @return bool
111
     */
112
    public static function unlink(string $path): bool
113
    {
114
        $isWindows = DIRECTORY_SEPARATOR === '\\';
115
116
        if (!$isWindows) {
117
            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
    public static function createDirectory(string $path, int $mode = 0775): bool
139
    {
140
        $path = static::normalizePath($path);
141
142
        if (is_dir($path)) {
143
            return true;
144
        }
145
        try {
146
            if (!mkdir($path, $mode, true) && !is_dir($path)) {
147
                return false;
148
            }
149
        } catch (\Exception $e) {
150
            if (!is_dir($path)) { // https://github.com/yiisoft/yii2/issues/9288
151
                throw new \RuntimeException("Failed to create directory \"$path\": " . $e->getMessage(), $e->getCode(), $e);
152
            }
153
        }
154
155
        try {
156
            return chmod($path, $mode);
157
        } catch (\Exception $e) {
158
            throw new \RuntimeException("Failed to change permissions for directory \"$path\": " . $e->getMessage(), $e->getCode(), $e);
159
        }
160
    }
161
162
    /**
163
     * Copies a whole directory as another one.
164
     *
165
     * The files and sub-directories will also be copied over.
166
     *
167
     * @param string $source the source directory.
168
     * @param string $destination the destination directory.
169
     * @param array $options options for directory copy. Valid options are:
170
     *
171
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
172
     * - fileMode:  integer, the permission to be set for newly copied files. Defaults to the current environment
173
     *   setting.
174
     * - filter: callback, a PHP callback that is called for each directory or file.
175
     *   The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered.
176
     *   The callback can return one of the following values:
177
     *
178
     *   * true: the directory or file will be copied (the "only" and "except" options will be ignored).
179
     *   * false: the directory or file will NOT be copied (the "only" and "except" options will be ignored).
180
     *   * null: the "only" and "except" options will determine whether the directory or file should be copied.
181
     *
182
     * - only: array, list of patterns that the file paths should match if they want to be copied. A path matches a
183
     *   pattern if it contains the pattern string at its end. For example, '.php' matches all file paths ending with
184
     *   '.php'.
185
     *   Note, the '/' characters in a pattern matches both '/' and '\' in the paths. If a file path matches a pattern
186
     *   in both "only" and "except", it will NOT be copied.
187
     * - except: array, list of patterns that the files or directories should match if they want to be excluded from
188
     *   being copied. A path matches a pattern if it contains the pattern string at its end. Patterns ending with '/'
189
     *   apply to directory paths only, and patterns not ending with '/' apply to file paths only. For example, '/a/b'
190
     *   matches all file paths ending with '/a/b'; and '.svn/' matches directory paths ending with '.svn'. Note, the
191
     *   '/' characters in a pattern matches both '/' and '\' in the paths.
192
     * - caseSensitive: boolean, whether patterns specified at "only" or "except" should be case sensitive. Defaults to
193
     *   true.
194
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
195
     * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file. If the callback
196
     *   returns false, the copy operation for the sub-directory or file will be cancelled. The signature of the
197
     *   callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file to be copied from,
198
     *   while `$to` is the copy target.
199
     * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied.
200
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file
201
     *   copied from, while `$to` is the copy target.
202
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating
203
     *   directories that do not contain files. This affects directories that do not contain files initially as well as
204
     *   directories that do not contain files at the target destination because files have been filtered via `only` or
205
     *   `except`. Defaults to true.
206
     *
207
     * @throws \InvalidArgumentException if unable to open directory
208
     * @throws \Exception
209
     *
210
     * @return void
211
     */
212
    public static function copyDirectory(string $source, string $destination, array $options = []): void
213
    {
214
        $source = static::normalizePath($source);
215
        $destination = static::normalizePath($destination);
216
217
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
218
            throw new \InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
219
        }
220
221
        $destinationExists = is_dir($destination);
222
223
        if (!$destinationExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
224
            static::createDirectory($destination, $options['dirMode'] ?? 0775);
225
            $destinationExists = true;
226
        }
227
228
        $handle = opendir($source);
229
230
        if ($handle === false) {
231
            throw new \InvalidArgumentException("Unable to open directory: $source");
232
        }
233
234
        if (!isset($options['basePath'])) {
235
            // this should be done only once
236
            $options['basePath'] = realpath($source);
237
            $options = static::normalizeOptions($options);
238
        }
239
240
        while (($file = readdir($handle)) !== false) {
241
            if ($file === '.' || $file === '..') {
242
                continue;
243
            }
244
245
            $from = $source . '/' . $file;
246
            $to = $destination . '/' . $file;
247
248
            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...
249
                if (isset($options['beforeCopy']) && !\call_user_func($options['beforeCopy'], $from, $to)) {
250
                    continue;
251
                }
252
                if (is_file($from)) {
253
                    if (!$destinationExists) {
254
                        // delay creation of destination directory until the first file is copied to avoid creating empty directories
255
                        static::createDirectory($destination, $options['dirMode'] ?? 0775);
256
                        $destinationExists = true;
257
                    }
258
                    copy($from, $to);
259
                    if (isset($options['fileMode'])) {
260
                        @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

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

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

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