Completed
Push — filehelper-unlink ( 5d3071 )
by Alexander
10:05
created

BaseFileHelper::unlink()   B

Complexity

Conditions 5
Paths 4

Size

Total Lines 21
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 11.4436

Importance

Changes 0
Metric Value
dl 0
loc 21
rs 8.7624
c 0
b 0
f 0
ccs 4
cts 11
cp 0.3636
cc 5
eloc 12
nc 4
nop 1
crap 11.4436
1
<?php
2
/**
3
 * @link http://www.yiiframework.com/
4
 * @copyright Copyright (c) 2008 Yii Software LLC
5
 * @license http://www.yiiframework.com/license/
6
 */
7
8
namespace yii\helpers;
9
10
use Yii;
11
use yii\base\ErrorException;
12
use yii\base\InvalidConfigException;
13
use yii\base\InvalidParamException;
14
15
/**
16
 * BaseFileHelper provides concrete implementation for [[FileHelper]].
17
 *
18
 * Do not use BaseFileHelper. Use [[FileHelper]] instead.
19
 *
20
 * @author Qiang Xue <[email protected]>
21
 * @author Alex Makarov <[email protected]>
22
 * @since 2.0
23
 */
24
class BaseFileHelper
25
{
26
    const PATTERN_NODIR = 1;
27
    const PATTERN_ENDSWITH = 4;
28
    const PATTERN_MUSTBEDIR = 8;
29
    const PATTERN_NEGATIVE = 16;
30
    const PATTERN_CASE_INSENSITIVE = 32;
31
32
    /**
33
     * @var string the path (or alias) of a PHP file containing MIME type information.
34
     */
35
    public static $mimeMagicFile = '@yii/helpers/mimeTypes.php';
36
37
    /**
38
     * @var string the path (or alias) of a PHP file containing MIME aliases.
39
     * @since 2.0.14
40
     */
41
    public static $mimeAliasesFile = '@yii/helpers/mimeAliases.php';
42
43
44
    /**
45
     * Normalizes a file/directory path.
46
     *
47
     * The normalization does the following work:
48
     *
49
     * - Convert all directory separators into `DIRECTORY_SEPARATOR` (e.g. "\a/b\c" becomes "/a/b/c")
50
     * - Remove trailing directory separators (e.g. "/a/b/c/" becomes "/a/b/c")
51
     * - Turn multiple consecutive slashes into a single one (e.g. "/a///b/c" becomes "/a/b/c")
52
     * - Remove ".." and "." based on their meanings (e.g. "/a/./b/../c" becomes "/a/c")
53
     *
54
     * @param string $path the file/directory path to be normalized
55
     * @param string $ds the directory separator to be used in the normalized result. Defaults to `DIRECTORY_SEPARATOR`.
56
     * @return string the normalized file/directory path
57
     */
58 34
    public static function normalizePath($path, $ds = DIRECTORY_SEPARATOR)
59
    {
60 34
        $path = rtrim(strtr($path, '/\\', $ds . $ds), $ds);
61 34
        if (strpos($ds . $path, "{$ds}.") === false && strpos($path, "{$ds}{$ds}") === false) {
62 33
            return $path;
63
        }
64
        // the path may contain ".", ".." or double slashes, need to clean them up
65 1
        $parts = [];
66 1
        foreach (explode($ds, $path) as $part) {
67 1
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
68 1
                array_pop($parts);
69 1
            } elseif ($part === '.' || $part === '' && !empty($parts)) {
70 1
                continue;
71
            } else {
72 1
                $parts[] = $part;
73
            }
74
        }
75 1
        $path = implode($ds, $parts);
76 1
        return $path === '' ? '.' : $path;
77
    }
78
79
    /**
80
     * Returns the localized version of a specified file.
81
     *
82
     * The searching is based on the specified language code. In particular,
83
     * a file with the same name will be looked for under the subdirectory
84
     * whose name is the same as the language code. For example, given the file "path/to/view.php"
85
     * and language code "zh-CN", the localized file will be looked for as
86
     * "path/to/zh-CN/view.php". If the file is not found, it will try a fallback with just a language code that is
87
     * "zh" i.e. "path/to/zh/view.php". If it is not found as well the original file will be returned.
88
     *
89
     * If the target and the source language codes are the same,
90
     * the original file will be returned.
91
     *
92
     * @param string $file the original file
93
     * @param string $language the target language that the file should be localized to.
94
     * If not set, the value of [[\yii\base\Application::language]] will be used.
95
     * @param string $sourceLanguage the language that the original file is in.
96
     * If not set, the value of [[\yii\base\Application::sourceLanguage]] will be used.
97
     * @return string the matching localized file, or the original file if the localized version is not found.
98
     * If the target and the source language codes are the same, the original file will be returned.
99
     */
100 72
    public static function localize($file, $language = null, $sourceLanguage = null)
101
    {
102 72
        if ($language === null) {
103 71
            $language = Yii::$app->language;
104
        }
105 72
        if ($sourceLanguage === null) {
106 71
            $sourceLanguage = Yii::$app->sourceLanguage;
107
        }
108 72
        if ($language === $sourceLanguage) {
109 72
            return $file;
110
        }
111 1
        $desiredFile = dirname($file) . DIRECTORY_SEPARATOR . $language . DIRECTORY_SEPARATOR . basename($file);
112 1
        if (is_file($desiredFile)) {
113 1
            return $desiredFile;
114
        }
115
116
        $language = substr($language, 0, 2);
117
        if ($language === $sourceLanguage) {
118
            return $file;
119
        }
120
        $desiredFile = dirname($file) . DIRECTORY_SEPARATOR . $language . DIRECTORY_SEPARATOR . basename($file);
121
122
        return is_file($desiredFile) ? $desiredFile : $file;
123
    }
124
125
    /**
126
     * Determines the MIME type of the specified file.
127
     * This method will first try to determine the MIME type based on
128
     * [finfo_open](http://php.net/manual/en/function.finfo-open.php). If the `fileinfo` extension is not installed,
129
     * it will fall back to [[getMimeTypeByExtension()]] when `$checkExtension` is true.
130
     * @param string $file the file name.
131
     * @param string $magicFile name of the optional magic database file (or alias), usually something like `/path/to/magic.mime`.
132
     * This will be passed as the second parameter to [finfo_open()](http://php.net/manual/en/function.finfo-open.php)
133
     * when the `fileinfo` extension is installed. If the MIME type is being determined based via [[getMimeTypeByExtension()]]
134
     * and this is null, it will use the file specified by [[mimeMagicFile]].
135
     * @param bool $checkExtension whether to use the file extension to determine the MIME type in case
136
     * `finfo_open()` cannot determine it.
137
     * @return string the MIME type (e.g. `text/plain`). Null is returned if the MIME type cannot be determined.
138
     * @throws InvalidConfigException when the `fileinfo` PHP extension is not installed and `$checkExtension` is `false`.
139
     */
140 25
    public static function getMimeType($file, $magicFile = null, $checkExtension = true)
141
    {
142 25
        if ($magicFile !== null) {
143
            $magicFile = Yii::getAlias($magicFile);
144
        }
145 25
        if (!extension_loaded('fileinfo')) {
146
            if ($checkExtension) {
147
                return static::getMimeTypeByExtension($file, $magicFile);
0 ignored issues
show
Bug introduced by
It seems like $magicFile defined by \Yii::getAlias($magicFile) on line 143 can also be of type boolean; however, yii\helpers\BaseFileHelp...etMimeTypeByExtension() does only seem to accept string|null, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
148
            }
149
150
            throw new InvalidConfigException('The fileinfo PHP extension is not installed.');
151
        }
152 25
        $info = finfo_open(FILEINFO_MIME_TYPE, $magicFile);
153
154 25
        if ($info) {
155 25
            $result = finfo_file($info, $file);
156 25
            finfo_close($info);
157
158 25
            if ($result !== false) {
159 25
                return $result;
160
            }
161
        }
162
163
        return $checkExtension ? static::getMimeTypeByExtension($file, $magicFile) : null;
0 ignored issues
show
Bug introduced by
It seems like $magicFile defined by \Yii::getAlias($magicFile) on line 143 can also be of type boolean; however, yii\helpers\BaseFileHelp...etMimeTypeByExtension() does only seem to accept string|null, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
164
    }
165
166
    /**
167
     * Determines the MIME type based on the extension name of the specified file.
168
     * This method will use a local map between extension names and MIME types.
169
     * @param string $file the file name.
170
     * @param string $magicFile the path (or alias) of the file that contains all available MIME type information.
171
     * If this is not set, the file specified by [[mimeMagicFile]] will be used.
172
     * @return string|null the MIME type. Null is returned if the MIME type cannot be determined.
173
     */
174 8
    public static function getMimeTypeByExtension($file, $magicFile = null)
175
    {
176 8
        $mimeTypes = static::loadMimeTypes($magicFile);
177
178 8
        if (($ext = pathinfo($file, PATHINFO_EXTENSION)) !== '') {
179 8
            $ext = strtolower($ext);
180 8
            if (isset($mimeTypes[$ext])) {
181 8
                return $mimeTypes[$ext];
182
            }
183
        }
184
185 1
        return null;
186
    }
187
188
    /**
189
     * Determines the extensions by given MIME type.
190
     * This method will use a local map between extension names and MIME types.
191
     * @param string $mimeType file MIME type.
192
     * @param string $magicFile the path (or alias) of the file that contains all available MIME type information.
193
     * If this is not set, the file specified by [[mimeMagicFile]] will be used.
194
     * @return array the extensions corresponding to the specified MIME type
195
     */
196 12
    public static function getExtensionsByMimeType($mimeType, $magicFile = null)
197
    {
198 12
        $aliases = static::loadMimeAliases(static::$mimeAliasesFile);
199 12
        if (isset($aliases[$mimeType])) {
200
            $mimeType = $aliases[$mimeType];
201
        }
202
203 12
        $mimeTypes = static::loadMimeTypes($magicFile);
204 12
        return array_keys($mimeTypes, mb_strtolower($mimeType, 'UTF-8'), true);
205
    }
206
207
    private static $_mimeTypes = [];
208
209
    /**
210
     * Loads MIME types from the specified file.
211
     * @param string $magicFile the path (or alias) of the file that contains all available MIME type information.
212
     * If this is not set, the file specified by [[mimeMagicFile]] will be used.
213
     * @return array the mapping from file extensions to MIME types
214
     */
215 20
    protected static function loadMimeTypes($magicFile)
216
    {
217 20
        if ($magicFile === null) {
218 20
            $magicFile = static::$mimeMagicFile;
219
        }
220 20
        $magicFile = Yii::getAlias($magicFile);
221 20
        if (!isset(self::$_mimeTypes[$magicFile])) {
222 1
            self::$_mimeTypes[$magicFile] = require $magicFile;
223
        }
224
225 20
        return self::$_mimeTypes[$magicFile];
226
    }
227
228
    private static $_mimeAliases = [];
229
230
    /**
231
     * Loads MIME aliases from the specified file.
232
     * @param string $aliasesFile the path (or alias) of the file that contains MIME type aliases.
233
     * If this is not set, the file specified by [[mimeAliasesFile]] will be used.
234
     * @return array the mapping from file extensions to MIME types
235
     * @since 2.0.14
236
     */
237 12
    protected static function loadMimeAliases($aliasesFile)
238
    {
239 12
        if ($aliasesFile === null) {
240
            $aliasesFile = static::$mimeAliasesFile;
241
        }
242 12
        $aliasesFile = Yii::getAlias($aliasesFile);
243 12
        if (!isset(self::$_mimeAliases[$aliasesFile])) {
244 1
            self::$_mimeAliases[$aliasesFile] = require $aliasesFile;
245
        }
246
247 12
        return self::$_mimeAliases[$aliasesFile];
248
    }
249
250
    /**
251
     * Copies a whole directory as another one.
252
     * The files and sub-directories will also be copied over.
253
     * @param string $src the source directory
254
     * @param string $dst the destination directory
255
     * @param array $options options for directory copy. Valid options are:
256
     *
257
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
258
     * - fileMode:  integer, the permission to be set for newly copied files. Defaults to the current environment setting.
259
     * - filter: callback, a PHP callback that is called for each directory or file.
260
     *   The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered.
261
     *   The callback can return one of the following values:
262
     *
263
     *   * true: the directory or file will be copied (the "only" and "except" options will be ignored)
264
     *   * false: the directory or file will NOT be copied (the "only" and "except" options will be ignored)
265
     *   * null: the "only" and "except" options will determine whether the directory or file should be copied
266
     *
267
     * - only: array, list of patterns that the file paths should match if they want to be copied.
268
     *   A path matches a pattern if it contains the pattern string at its end.
269
     *   For example, '.php' matches all file paths ending with '.php'.
270
     *   Note, the '/' characters in a pattern matches both '/' and '\' in the paths.
271
     *   If a file path matches a pattern in both "only" and "except", it will NOT be copied.
272
     * - except: array, list of patterns that the files or directories should match if they want to be excluded from being copied.
273
     *   A path matches a pattern if it contains the pattern string at its end.
274
     *   Patterns ending with '/' apply to directory paths only, and patterns not ending with '/'
275
     *   apply to file paths only. For example, '/a/b' matches all file paths ending with '/a/b';
276
     *   and '.svn/' matches directory paths ending with '.svn'. Note, the '/' characters in a pattern matches
277
     *   both '/' and '\' in the paths.
278
     * - caseSensitive: boolean, whether patterns specified at "only" or "except" should be case sensitive. Defaults to true.
279
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
280
     * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file.
281
     *   If the callback returns false, the copy operation for the sub-directory or file will be cancelled.
282
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or
283
     *   file to be copied from, while `$to` is the copy target.
284
     * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied.
285
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or
286
     *   file copied from, while `$to` is the copy target.
287
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating directories
288
     *   that do not contain files. This affects directories that do not contain files initially as well as directories that
289
     *   do not contain files at the target destination because files have been filtered via `only` or `except`.
290
     *   Defaults to true. This option is available since version 2.0.12. Before 2.0.12 empty directories are always copied.
291
     * @throws \yii\base\InvalidParamException if unable to open directory
292
     */
293 17
    public static function copyDirectory($src, $dst, $options = [])
294
    {
295 17
        $src = static::normalizePath($src);
296 17
        $dst = static::normalizePath($dst);
297
298 17
        if ($src === $dst || strpos($dst, $src . DIRECTORY_SEPARATOR) === 0) {
299 2
            throw new InvalidParamException('Trying to copy a directory to itself or a subdirectory.');
300
        }
301 15
        $dstExists = is_dir($dst);
302 15
        if (!$dstExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
303 6
            static::createDirectory($dst, isset($options['dirMode']) ? $options['dirMode'] : 0775, true);
304 6
            $dstExists = true;
305
        }
306
307 15
        $handle = opendir($src);
308 15
        if ($handle === false) {
309
            throw new InvalidParamException("Unable to open directory: $src");
310
        }
311 15
        if (!isset($options['basePath'])) {
312
            // this should be done only once
313 15
            $options['basePath'] = realpath($src);
314 15
            $options = static::normalizeOptions($options);
315
        }
316 15
        while (($file = readdir($handle)) !== false) {
317 15
            if ($file === '.' || $file === '..') {
318 15
                continue;
319
            }
320 13
            $from = $src . DIRECTORY_SEPARATOR . $file;
321 13
            $to = $dst . DIRECTORY_SEPARATOR . $file;
322 13
            if (static::filterPath($from, $options)) {
323 13
                if (isset($options['beforeCopy']) && !call_user_func($options['beforeCopy'], $from, $to)) {
324 2
                    continue;
325
                }
326 11
                if (is_file($from)) {
327 11
                    if (!$dstExists) {
328
                        // delay creation of destination directory until the first file is copied to avoid creating empty directories
329 5
                        static::createDirectory($dst, isset($options['dirMode']) ? $options['dirMode'] : 0775, true);
330 5
                        $dstExists = true;
331
                    }
332 11
                    copy($from, $to);
333 11
                    if (isset($options['fileMode'])) {
334 11
                        @chmod($to, $options['fileMode']);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition here. This can introduce security issues, and is generally not recommended.

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...
335
                    }
336
                } else {
337
                    // recursive copy, defaults to true
338 8
                    if (!isset($options['recursive']) || $options['recursive']) {
339 7
                        static::copyDirectory($from, $to, $options);
340
                    }
341
                }
342 11
                if (isset($options['afterCopy'])) {
343
                    call_user_func($options['afterCopy'], $from, $to);
344
                }
345
            }
346
        }
347 15
        closedir($handle);
348 15
    }
349
350
    /**
351
     * Removes a directory (and all its content) recursively.
352
     *
353
     * @param string $dir the directory to be deleted recursively.
354
     * @param array $options options for directory remove. Valid options are:
355
     *
356
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
357
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
358
     *   Only symlink would be removed in that default case.
359
     *
360
     * @throws ErrorException in case of failure
361
     */
362 121
    public static function removeDirectory($dir, $options = [])
363
    {
364 121
        if (!is_dir($dir)) {
365 30
            return;
366
        }
367 120
        if (isset($options['traverseSymlinks']) && $options['traverseSymlinks'] || !is_link($dir)) {
368 120
            if (!($handle = opendir($dir))) {
369
                return;
370
            }
371 120
            while (($file = readdir($handle)) !== false) {
372 120
                if ($file === '.' || $file === '..') {
373 120
                    continue;
374
                }
375 102
                $path = $dir . DIRECTORY_SEPARATOR . $file;
376 102
                if (is_dir($path)) {
377 57
                    static::removeDirectory($path, $options);
378
                } else {
379 83
                    static::unlink($path);
380
                }
381
            }
382 120
            closedir($handle);
383
        }
384 120
        if (is_link($dir)) {
385 2
            static::unlink($dir);
386
        } else {
387 120
            rmdir($dir);
388
        }
389 120
    }
390
391
    /**
392
     * Removes a file or symlink in a cross-platform way
393
     *
394
     * @param string $path
395
     * @return bool
396
     *
397
     * @since 2.0.14
398
     */
399 84
    public static function unlink($path)
400
    {
401 84
        $isWindows = DIRECTORY_SEPARATOR === '\\';
402
403 84
        if (!$isWindows) {
404 84
            return unlink($path);
405
        }
406
407
        if (is_link($path) && is_dir($path)) {
408
            return rmdir($path);
409
        }
410
411
        try {
412
            return unlink($path);
413
        } catch (ErrorException $e) {
414
            // last resort measure for Windows
415
            $lines = [];
416
            exec("DEL /F/Q \"$path\"", $lines, $deleteError);
417
            return $deleteError !== 0;
418
        }
419
    }
420
421
    /**
422
     * Returns the files found under the specified directory and subdirectories.
423
     * @param string $dir the directory under which the files will be looked for.
424
     * @param array $options options for file searching. Valid options are:
425
     *
426
     * - `filter`: callback, a PHP callback that is called for each directory or file.
427
     *   The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered.
428
     *   The callback can return one of the following values:
429
     *
430
     *   * `true`: the directory or file will be returned (the `only` and `except` options will be ignored)
431
     *   * `false`: the directory or file will NOT be returned (the `only` and `except` options will be ignored)
432
     *   * `null`: the `only` and `except` options will determine whether the directory or file should be returned
433
     *
434
     * - `except`: array, list of patterns excluding from the results matching file or directory paths.
435
     *   Patterns ending with slash ('/') apply to directory paths only, and patterns not ending with '/'
436
     *   apply to file paths only. For example, '/a/b' matches all file paths ending with '/a/b';
437
     *   and `.svn/` matches directory paths ending with `.svn`.
438
     *   If the pattern does not contain a slash (`/`), it is treated as a shell glob pattern
439
     *   and checked for a match against the pathname relative to `$dir`.
440
     *   Otherwise, the pattern is treated as a shell glob suitable for consumption by `fnmatch(3)`
441
     *   with the `FNM_PATHNAME` flag: wildcards in the pattern will not match a `/` in the pathname.
442
     *   For example, `views/*.php` matches `views/index.php` but not `views/controller/index.php`.
443
     *   A leading slash matches the beginning of the pathname. For example, `/*.php` matches `index.php` but not `views/start/index.php`.
444
     *   An optional prefix `!` which negates the pattern; any matching file excluded by a previous pattern will become included again.
445
     *   If a negated pattern matches, this will override lower precedence patterns sources. Put a backslash (`\`) in front of the first `!`
446
     *   for patterns that begin with a literal `!`, for example, `\!important!.txt`.
447
     *   Note, the '/' characters in a pattern matches both '/' and '\' in the paths.
448
     * - `only`: array, list of patterns that the file paths should match if they are to be returned. Directory paths
449
     *   are not checked against them. Same pattern matching rules as in the `except` option are used.
450
     *   If a file path matches a pattern in both `only` and `except`, it will NOT be returned.
451
     * - `caseSensitive`: boolean, whether patterns specified at `only` or `except` should be case sensitive. Defaults to `true`.
452
     * - `recursive`: boolean, whether the files under the subdirectories should also be looked for. Defaults to `true`.
453
     * @return array files found under the directory, in no particular order. Ordering depends on the files system used.
454
     * @throws InvalidParamException if the dir is invalid.
455
     */
456 71
    public static function findFiles($dir, $options = [])
457
    {
458 71
        if (!is_dir($dir)) {
459
            throw new InvalidParamException("The dir argument must be a directory: $dir");
460
        }
461 71
        $dir = rtrim($dir, DIRECTORY_SEPARATOR);
462 71
        if (!isset($options['basePath'])) {
463
            // this should be done only once
464 71
            $options['basePath'] = realpath($dir);
465 71
            $options = static::normalizeOptions($options);
466
        }
467 71
        $list = [];
468 71
        $handle = opendir($dir);
469 71
        if ($handle === false) {
470
            throw new InvalidParamException("Unable to open directory: $dir");
471
        }
472 71
        while (($file = readdir($handle)) !== false) {
473 71
            if ($file === '.' || $file === '..') {
474 71
                continue;
475
            }
476 71
            $path = $dir . DIRECTORY_SEPARATOR . $file;
477 71
            if (static::filterPath($path, $options)) {
478 71
                if (is_file($path)) {
479 69
                    $list[] = $path;
480 20
                } elseif (is_dir($path) && (!isset($options['recursive']) || $options['recursive'])) {
481 19
                    $list = array_merge($list, static::findFiles($path, $options));
482
                }
483
            }
484
        }
485 71
        closedir($handle);
486
487 71
        return $list;
488
    }
489
490
    /**
491
     * Checks if the given file path satisfies the filtering options.
492
     * @param string $path the path of the file or directory to be checked
493
     * @param array $options the filtering options. See [[findFiles()]] for explanations of
494
     * the supported options.
495
     * @return bool whether the file or directory satisfies the filtering options.
496
     */
497 80
    public static function filterPath($path, $options)
498
    {
499 80
        if (isset($options['filter'])) {
500 1
            $result = call_user_func($options['filter'], $path);
501 1
            if (is_bool($result)) {
502 1
                return $result;
503
            }
504
        }
505
506 79
        if (empty($options['except']) && empty($options['only'])) {
507 23
            return true;
508
        }
509
510 60
        $path = str_replace('\\', '/', $path);
511
512 60
        if (!empty($options['except'])) {
513 41
            if (($except = self::lastExcludeMatchingFromList($options['basePath'], $path, $options['except'])) !== null) {
514 2
                return $except['flags'] & self::PATTERN_NEGATIVE;
515
            }
516
        }
517
518 60
        if (!empty($options['only']) && !is_dir($path)) {
519 59
            if (($except = self::lastExcludeMatchingFromList($options['basePath'], $path, $options['only'])) !== null) {
520
                // don't check PATTERN_NEGATIVE since those entries are not prefixed with !
521 57
                return true;
522
            }
523
524 19
            return false;
525
        }
526
527 18
        return true;
528
    }
529
530
    /**
531
     * Creates a new directory.
532
     *
533
     * This method is similar to the PHP `mkdir()` function except that
534
     * it uses `chmod()` to set the permission of the created directory
535
     * in order to avoid the impact of the `umask` setting.
536
     *
537
     * @param string $path path of the directory to be created.
538
     * @param int $mode the permission to be set for the created directory.
539
     * @param bool $recursive whether to create parent directories if they do not exist.
540
     * @return bool whether the directory is created successfully
541
     * @throws \yii\base\Exception if the directory could not be created (i.e. php error due to parallel changes)
542
     */
543 173
    public static function createDirectory($path, $mode = 0775, $recursive = true)
544
    {
545 173
        if (is_dir($path)) {
546 66
            return true;
547
        }
548 149
        $parentDir = dirname($path);
549
        // recurse if parent dir does not exist and we are not at the root of the file system.
550 149
        if ($recursive && !is_dir($parentDir) && $parentDir !== $path) {
551 5
            static::createDirectory($parentDir, $mode, true);
552
        }
553
        try {
554 149
            if (!mkdir($path, $mode)) {
555 149
                return false;
556
            }
557
        } catch (\Exception $e) {
558
            if (!is_dir($path)) {// https://github.com/yiisoft/yii2/issues/9288
559
                throw new \yii\base\Exception("Failed to create directory \"$path\": " . $e->getMessage(), $e->getCode(), $e);
560
            }
561
        }
562
        try {
563 149
            return chmod($path, $mode);
564
        } catch (\Exception $e) {
565
            throw new \yii\base\Exception("Failed to change permissions for directory \"$path\": " . $e->getMessage(), $e->getCode(), $e);
566
        }
567
    }
568
569
    /**
570
     * Performs a simple comparison of file or directory names.
571
     *
572
     * Based on match_basename() from dir.c of git 1.8.5.3 sources.
573
     *
574
     * @param string $baseName file or directory name to compare with the pattern
575
     * @param string $pattern the pattern that $baseName will be compared against
576
     * @param int|bool $firstWildcard location of first wildcard character in the $pattern
577
     * @param int $flags pattern flags
578
     * @return bool whether the name matches against pattern
579
     */
580 59
    private static function matchBasename($baseName, $pattern, $firstWildcard, $flags)
581
    {
582 59
        if ($firstWildcard === false) {
583 49
            if ($pattern === $baseName) {
584 49
                return true;
585
            }
586 49
        } elseif ($flags & self::PATTERN_ENDSWITH) {
587
            /* "*literal" matching against "fooliteral" */
588 48
            $n = StringHelper::byteLength($pattern);
589 48
            if (StringHelper::byteSubstr($pattern, 1, $n) === StringHelper::byteSubstr($baseName, -$n, $n)) {
590
                return true;
591
            }
592
        }
593
594 59
        $fnmatchFlags = 0;
595 59
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
596 1
            $fnmatchFlags |= FNM_CASEFOLD;
597
        }
598
599 59
        return fnmatch($pattern, $baseName, $fnmatchFlags);
600
    }
601
602
    /**
603
     * Compares a path part against a pattern with optional wildcards.
604
     *
605
     * Based on match_pathname() from dir.c of git 1.8.5.3 sources.
606
     *
607
     * @param string $path full path to compare
608
     * @param string $basePath base of path that will not be compared
609
     * @param string $pattern the pattern that path part will be compared against
610
     * @param int|bool $firstWildcard location of first wildcard character in the $pattern
611
     * @param int $flags pattern flags
612
     * @return bool whether the path part matches against pattern
613
     */
614 43
    private static function matchPathname($path, $basePath, $pattern, $firstWildcard, $flags)
615
    {
616
        // match with FNM_PATHNAME; the pattern has base implicitly in front of it.
617 43
        if (isset($pattern[0]) && $pattern[0] === '/') {
618 40
            $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern));
619 40
            if ($firstWildcard !== false && $firstWildcard !== 0) {
620
                $firstWildcard--;
621
            }
622
        }
623
624 43
        $namelen = StringHelper::byteLength($path) - (empty($basePath) ? 0 : StringHelper::byteLength($basePath) + 1);
625 43
        $name = StringHelper::byteSubstr($path, -$namelen, $namelen);
626
627 43
        if ($firstWildcard !== 0) {
628 43
            if ($firstWildcard === false) {
629 42
                $firstWildcard = StringHelper::byteLength($pattern);
630
            }
631
            // if the non-wildcard part is longer than the remaining pathname, surely it cannot match.
632 43
            if ($firstWildcard > $namelen) {
633 2
                return false;
634
            }
635
636 43
            if (strncmp($pattern, $name, $firstWildcard)) {
637 43
                return false;
638
            }
639 4
            $pattern = StringHelper::byteSubstr($pattern, $firstWildcard, StringHelper::byteLength($pattern));
640 4
            $name = StringHelper::byteSubstr($name, $firstWildcard, $namelen);
641
642
            // 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.
643 4
            if (empty($pattern) && empty($name)) {
644 3
                return true;
645
            }
646
        }
647
648 2
        $fnmatchFlags = FNM_PATHNAME;
649 2
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
650
            $fnmatchFlags |= FNM_CASEFOLD;
651
        }
652
653 2
        return fnmatch($pattern, $name, $fnmatchFlags);
654
    }
655
656
    /**
657
     * Scan the given exclude list in reverse to see whether pathname
658
     * should be ignored.  The first match (i.e. the last on the list), if
659
     * any, determines the fate.  Returns the element which
660
     * matched, or null for undecided.
661
     *
662
     * Based on last_exclude_matching_from_list() from dir.c of git 1.8.5.3 sources.
663
     *
664
     * @param string $basePath
665
     * @param string $path
666
     * @param array $excludes list of patterns to match $path against
667
     * @return array|null null or one of $excludes item as an array with keys: 'pattern', 'flags'
668
     * @throws InvalidParamException if any of the exclude patterns is not a string or an array with keys: pattern, flags, firstWildcard.
669
     */
670 60
    private static function lastExcludeMatchingFromList($basePath, $path, $excludes)
671
    {
672 60
        foreach (array_reverse($excludes) as $exclude) {
673 60
            if (is_string($exclude)) {
674
                $exclude = self::parseExcludePattern($exclude, false);
675
            }
676 60
            if (!isset($exclude['pattern']) || !isset($exclude['flags']) || !isset($exclude['firstWildcard'])) {
677
                throw new InvalidParamException('If exclude/include pattern is an array it must contain the pattern, flags and firstWildcard keys.');
678
            }
679 60
            if ($exclude['flags'] & self::PATTERN_MUSTBEDIR && !is_dir($path)) {
680
                continue;
681
            }
682
683 60
            if ($exclude['flags'] & self::PATTERN_NODIR) {
684 59
                if (self::matchBasename(basename($path), $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) {
685 57
                    return $exclude;
686
                }
687 58
                continue;
688
            }
689
690 43
            if (self::matchPathname($path, $basePath, $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) {
691 43
                return $exclude;
692
            }
693
        }
694
695 59
        return null;
696
    }
697
698
    /**
699
     * Processes the pattern, stripping special characters like / and ! from the beginning and settings flags instead.
700
     * @param string $pattern
701
     * @param bool $caseSensitive
702
     * @throws \yii\base\InvalidParamException
703
     * @return array with keys: (string) pattern, (int) flags, (int|bool) firstWildcard
704
     */
705 60
    private static function parseExcludePattern($pattern, $caseSensitive)
706
    {
707 60
        if (!is_string($pattern)) {
708
            throw new InvalidParamException('Exclude/include pattern must be a string.');
709
        }
710
711
        $result = [
712 60
            'pattern' => $pattern,
713 60
            'flags' => 0,
714
            'firstWildcard' => false,
715
        ];
716
717 60
        if (!$caseSensitive) {
718 1
            $result['flags'] |= self::PATTERN_CASE_INSENSITIVE;
719
        }
720
721 60
        if (!isset($pattern[0])) {
722
            return $result;
723
        }
724
725 60
        if ($pattern[0] === '!') {
726
            $result['flags'] |= self::PATTERN_NEGATIVE;
727
            $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern));
728
        }
729 60
        if (StringHelper::byteLength($pattern) && StringHelper::byteSubstr($pattern, -1, 1) === '/') {
730
            $pattern = StringHelper::byteSubstr($pattern, 0, -1);
731
            $result['flags'] |= self::PATTERN_MUSTBEDIR;
732
        }
733 60
        if (strpos($pattern, '/') === false) {
734 59
            $result['flags'] |= self::PATTERN_NODIR;
735
        }
736 60
        $result['firstWildcard'] = self::firstWildcardInPattern($pattern);
737 60
        if ($pattern[0] === '*' && self::firstWildcardInPattern(StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern))) === false) {
738 48
            $result['flags'] |= self::PATTERN_ENDSWITH;
739
        }
740 60
        $result['pattern'] = $pattern;
741
742 60
        return $result;
743
    }
744
745
    /**
746
     * Searches for the first wildcard character in the pattern.
747
     * @param string $pattern the pattern to search in
748
     * @return int|bool position of first wildcard character or false if not found
749
     */
750 60
    private static function firstWildcardInPattern($pattern)
751
    {
752 60
        $wildcards = ['*', '?', '[', '\\'];
753 60
        $wildcardSearch = function ($r, $c) use ($pattern) {
754 60
            $p = strpos($pattern, $c);
755
756 60
            return $r === false ? $p : ($p === false ? $r : min($r, $p));
757 60
        };
758
759 60
        return array_reduce($wildcards, $wildcardSearch, false);
760
    }
761
762
    /**
763
     * @param array $options raw options
764
     * @return array normalized options
765
     * @since 2.0.12
766
     */
767 82
    protected static function normalizeOptions(array $options)
768
    {
769 82
        if (!array_key_exists('caseSensitive', $options)) {
770 81
            $options['caseSensitive'] = true;
771
        }
772 82
        if (isset($options['except'])) {
773 41
            foreach ($options['except'] as $key => $value) {
774 41
                if (is_string($value)) {
775 41
                    $options['except'][$key] = self::parseExcludePattern($value, $options['caseSensitive']);
776
                }
777
            }
778
        }
779 82
        if (isset($options['only'])) {
780 59
            foreach ($options['only'] as $key => $value) {
781 59
                if (is_string($value)) {
782 59
                    $options['only'][$key] = self::parseExcludePattern($value, $options['caseSensitive']);
783
                }
784
            }
785
        }
786
787 82
        return $options;
788
    }
789
}
790