Passed
Pull Request — master (#5)
by Wilmer
01:18
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
37 17
        if ($isWindowsShare) {
38 1
            $path = substr($path, 2);
39
        }
40
41 17
        $path = rtrim(strtr($path, '/\\', '//'), '/');
42
43 17
        if (strpos('/' . $path, '/.') === false && strpos($path, '//') === false) {
44 17
            if ($isWindowsShare) {
45 1
                $path = $path = '\\\\' . $path;
0 ignored issues
show
Unused Code introduced by
The assignment to $path is dead and can be removed.
Loading history...
46
            }
47 17
            return $path;
48
        }
49
50 1
        $parts = [];
51
52 1
        foreach (explode('/', $path) as $part) {
53 1
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
54 1
                array_pop($parts);
55 1
            } elseif ($part !== '.' && ($part !== '' || empty($parts))) {
56 1
                $parts[] = $part;
57
            }
58
        }
59 1
        $path = implode('/', $parts);
60 1
        if ($isWindowsShare) {
61 1
            $path = '\\\\' . $path;
62
        }
63 1
        return $path === '' ? '.' : $path;
64
    }
65
66
    /**
67
     * Removes a directory (and all its content) recursively.
68
     *
69
     * @param string $directory the directory to be deleted recursively.
70
     * @param array $options options for directory remove. Valid options are:
71
     *
72
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
73
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
74
     *   Only symlink would be removed in that default case.
75
     *
76
     * @return void
77
     */
78 17
    public static function removeDirectory(string $directory, array $options = []): void
79
    {
80 17
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
81 17
            if (!($handle = @opendir($directory))) {
82 1
                return;
83
            }
84 17
            while (($file = readdir($handle)) !== false) {
85 17
                if ($file === '.' || $file === '..') {
86 17
                    continue;
87
                }
88 16
                $path = $directory . '/' . $file;
89 16
                if (is_dir($path)) {
90 16
                    self::removeDirectory($path, $options);
91
                } else {
92 10
                    self::unlink($path);
93
                }
94
            }
95 17
            closedir($handle);
96
        }
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
        
144
        try {
145 17
            if (!mkdir($path, $mode, true) && !is_dir($path)) {
146 17
                return false;
147
            }
148
        } catch (\Exception $e) {
149
            if (!is_dir($path)) { // https://github.com/yiisoft/yii2/issues/9288
150
                throw new \RuntimeException("Failed to create directory \"$path\": " . $e->getMessage(), $e->getCode(), $e);
151
            }
152
        }
153
154
        try {
155 17
            return chmod($path, $mode);
156
        } catch (\Exception $e) {
157
            throw new \RuntimeException("Failed to change permissions for directory \"$path\": " . $e->getMessage(), $e->getCode(), $e);
158
        }
159
    }
160
161
    /**
162
     * Copies a whole directory as another one.
163
     *
164
     * The files and sub-directories will also be copied over.
165
     *
166
     * @param string $source the source directory.
167
     * @param string $destination the destination directory.
168
     * @param array $options options for directory copy. Valid options are:
169
     *
170
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
171
     * - fileMode:  integer, the permission to be set for newly copied files. Defaults to the current environment
172
     *   setting.
173
     * - filter: callback, a PHP callback that is called for each directory or file.
174
     *   The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered.
175
     *   The callback can return one of the following values:
176
     *
177
     *   * true: the directory or file will be copied (the "only" and "except" options will be ignored).
178
     *   * false: the directory or file will NOT be copied (the "only" and "except" options will be ignored).
179
     *   * null: the "only" and "except" options will determine whether the directory or file should be copied.
180
     *
181
     * - only: array, list of patterns that the file paths should match if they want to be copied. A path matches a
182
     *   pattern if it contains the pattern string at its end. For example, '.php' matches all file paths ending with
183
     *   '.php'.
184
     *   Note, the '/' characters in a pattern matches both '/' and '\' in the paths. If a file path matches a pattern
185
     *   in both "only" and "except", it will NOT be copied.
186
     * - except: array, list of patterns that the files or directories should match if they want to be excluded from
187
     *   being copied. A path matches a pattern if it contains the pattern string at its end. Patterns ending with '/'
188
     *   apply to directory paths only, and patterns not ending with '/' apply to file paths only. For example, '/a/b'
189
     *   matches all file paths ending with '/a/b'; and '.svn/' matches directory paths ending with '.svn'. Note, the
190
     *   '/' characters in a pattern matches both '/' and '\' in the paths.
191
     * - caseSensitive: boolean, whether patterns specified at "only" or "except" should be case sensitive. Defaults to
192
     *   true.
193
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
194
     * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file. If the callback
195
     *   returns false, the copy operation for the sub-directory or file will be cancelled. The signature of the
196
     *   callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file to be copied from,
197
     *   while `$to` is the copy target.
198
     * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied.
199
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or file
200
     *   copied from, while `$to` is the copy target.
201
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating
202
     *   directories that do not contain files. This affects directories that do not contain files initially as well as
203
     *   directories that do not contain files at the target destination because files have been filtered via `only` or
204
     *   `except`. Defaults to true.
205
     *
206
     * @throws \InvalidArgumentException if unable to open directory
207
     * @throws \Exception
208
     *
209
     * @return void
210
     */
211 11
    public static function copyDirectory(string $source, string $destination, array $options = []): void
212
    {
213 11
        $source = static::normalizePath($source);
214 11
        $destination = static::normalizePath($destination);
215
216 11
        if ($source === $destination || strpos($destination, $source . '/') === 0) {
217 2
            throw new \InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
218
        }
219
220 9
        $destinationExists = is_dir($destination);
221
222 9
        if (!$destinationExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
223 5
            static::createDirectory($destination, $options['dirMode'] ?? 0775);
224 5
            $destinationExists = true;
225
        }
226
227 9
        $handle = opendir($source);
228
229 9
        if ($handle === false) {
230
            throw new \InvalidArgumentException("Unable to open directory: $source");
231
        }
232
233 9
        if (!isset($options['basePath'])) {
234
            // this should be done only once
235 9
            $options['basePath'] = realpath($source);
236 9
            $options = static::normalizeOptions($options);
237
        }
238
239 9
        while (($file = readdir($handle)) !== false) {
240 9
            if ($file === '.' || $file === '..') {
241 9
                continue;
242
            }
243
244 7
            $from = $source . '/' . $file;
245 7
            $to = $destination . '/' . $file;
246
247 7
            if (static::filterPath($from, $options)) {
248 7
                if (isset($options['beforeCopy']) && !\call_user_func($options['beforeCopy'], $from, $to)) {
249
                    continue;
250
                }
251 7
                if (is_file($from)) {
252 7
                    if (!$destinationExists) {
253
                        // delay creation of destination directory until the first file is copied to avoid creating empty directories
254 2
                        static::createDirectory($destination, $options['dirMode'] ?? 0775);
255 2
                        $destinationExists = true;
256
                    }
257 7
                    copy($from, $to);
258 7
                    if (isset($options['fileMode'])) {
259 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

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

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

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