Completed
Push — master ( 137a93...fa4d76 )
by Carsten
20:17 queued 11:37
created

BaseFileHelper::matchBasename()   B

Complexity

Conditions 6
Paths 8

Size

Total Lines 21
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 6.0208

Importance

Changes 0
Metric Value
dl 0
loc 21
ccs 11
cts 12
cp 0.9167
rs 8.7624
c 0
b 0
f 0
cc 6
eloc 12
nc 8
nop 4
crap 6.0208
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
    /**
39
     * Normalizes a file/directory path.
40
     * The normalization does the following work:
41
     *
42
     * - Convert all directory separators into `DIRECTORY_SEPARATOR` (e.g. "\a/b\c" becomes "/a/b/c")
43
     * - Remove trailing directory separators (e.g. "/a/b/c/" becomes "/a/b/c")
44
     * - Turn multiple consecutive slashes into a single one (e.g. "/a///b/c" becomes "/a/b/c")
45
     * - Remove ".." and "." based on their meanings (e.g. "/a/./b/../c" becomes "/a/c")
46
     *
47
     * @param string $path the file/directory path to be normalized
48
     * @param string $ds the directory separator to be used in the normalized result. Defaults to `DIRECTORY_SEPARATOR`.
49
     * @return string the normalized file/directory path
50
     */
51 28
    public static function normalizePath($path, $ds = DIRECTORY_SEPARATOR)
52
    {
53 28
        $path = rtrim(strtr($path, '/\\', $ds . $ds), $ds);
54 28
        if (strpos($ds . $path, "{$ds}.") === false && strpos($path, "{$ds}{$ds}") === false) {
55 27
            return $path;
56
        }
57
        // the path may contain ".", ".." or double slashes, need to clean them up
58 1
        $parts = [];
59 1
        foreach (explode($ds, $path) as $part) {
60 1
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
61 1
                array_pop($parts);
62 1
            } elseif ($part === '.' || $part === '' && !empty($parts)) {
63 1
                continue;
64
            } else {
65 1
                $parts[] = $part;
66
            }
67
        }
68 1
        $path = implode($ds, $parts);
69 1
        return $path === '' ? '.' : $path;
70
    }
71
72
    /**
73
     * Returns the localized version of a specified file.
74
     *
75
     * The searching is based on the specified language code. In particular,
76
     * a file with the same name will be looked for under the subdirectory
77
     * whose name is the same as the language code. For example, given the file "path/to/view.php"
78
     * and language code "zh-CN", the localized file will be looked for as
79
     * "path/to/zh-CN/view.php". If the file is not found, it will try a fallback with just a language code that is
80
     * "zh" i.e. "path/to/zh/view.php". If it is not found as well the original file will be returned.
81
     *
82
     * If the target and the source language codes are the same,
83
     * the original file will be returned.
84
     *
85
     * @param string $file the original file
86
     * @param string $language the target language that the file should be localized to.
87
     * If not set, the value of [[\yii\base\Application::language]] will be used.
88
     * @param string $sourceLanguage the language that the original file is in.
89
     * If not set, the value of [[\yii\base\Application::sourceLanguage]] will be used.
90
     * @return string the matching localized file, or the original file if the localized version is not found.
91
     * If the target and the source language codes are the same, the original file will be returned.
92
     */
93 69
    public static function localize($file, $language = null, $sourceLanguage = null)
94
    {
95 69
        if ($language === null) {
96 68
            $language = Yii::$app->language;
97
        }
98 69
        if ($sourceLanguage === null) {
99 68
            $sourceLanguage = Yii::$app->sourceLanguage;
100
        }
101 69
        if ($language === $sourceLanguage) {
102 69
            return $file;
103
        }
104 1
        $desiredFile = dirname($file) . DIRECTORY_SEPARATOR . $language . DIRECTORY_SEPARATOR . basename($file);
105 1
        if (is_file($desiredFile)) {
106 1
            return $desiredFile;
107
        } else {
108
            $language = substr($language, 0, 2);
109
            if ($language === $sourceLanguage) {
110
                return $file;
111
            }
112
            $desiredFile = dirname($file) . DIRECTORY_SEPARATOR . $language . DIRECTORY_SEPARATOR . basename($file);
113
114
            return is_file($desiredFile) ? $desiredFile : $file;
115
        }
116
    }
117
118
    /**
119
     * Determines the MIME type of the specified file.
120
     * This method will first try to determine the MIME type based on
121
     * [finfo_open](http://php.net/manual/en/function.finfo-open.php). If the `fileinfo` extension is not installed,
122
     * it will fall back to [[getMimeTypeByExtension()]] when `$checkExtension` is true.
123
     * @param string $file the file name.
124
     * @param string $magicFile name of the optional magic database file (or alias), usually something like `/path/to/magic.mime`.
125
     * This will be passed as the second parameter to [finfo_open()](http://php.net/manual/en/function.finfo-open.php)
126
     * when the `fileinfo` extension is installed. If the MIME type is being determined based via [[getMimeTypeByExtension()]]
127
     * and this is null, it will use the file specified by [[mimeMagicFile]].
128
     * @param bool $checkExtension whether to use the file extension to determine the MIME type in case
129
     * `finfo_open()` cannot determine it.
130
     * @return string the MIME type (e.g. `text/plain`). Null is returned if the MIME type cannot be determined.
131
     * @throws InvalidConfigException when the `fileinfo` PHP extension is not installed and `$checkExtension` is `false`.
132
     */
133 12
    public static function getMimeType($file, $magicFile = null, $checkExtension = true)
134
    {
135 12
        if ($magicFile !== null) {
136
            $magicFile = Yii::getAlias($magicFile);
137
        }
138 12
        if (!extension_loaded('fileinfo')) {
139
            if ($checkExtension) {
140
                return static::getMimeTypeByExtension($file, $magicFile);
0 ignored issues
show
Bug introduced by
It seems like $magicFile defined by \Yii::getAlias($magicFile) on line 136 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...
141
            } else {
142
                throw new InvalidConfigException('The fileinfo PHP extension is not installed.');
143
            }
144
        }
145 12
        $info = finfo_open(FILEINFO_MIME_TYPE, $magicFile);
146
147 12
        if ($info) {
148 12
            $result = finfo_file($info, $file);
149 12
            finfo_close($info);
150
151 12
            if ($result !== false) {
152 12
                return $result;
153
            }
154
        }
155
156
        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 136 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...
157
    }
158
159
    /**
160
     * Determines the MIME type based on the extension name of the specified file.
161
     * This method will use a local map between extension names and MIME types.
162
     * @param string $file the file name.
163
     * @param string $magicFile the path (or alias) of the file that contains all available MIME type information.
164
     * If this is not set, the file specified by [[mimeMagicFile]] will be used.
165
     * @return string the MIME type. Null is returned if the MIME type cannot be determined.
166
     */
167 8
    public static function getMimeTypeByExtension($file, $magicFile = null)
168
    {
169 8
        $mimeTypes = static::loadMimeTypes($magicFile);
170
171 8
        if (($ext = pathinfo($file, PATHINFO_EXTENSION)) !== '') {
172 8
            $ext = strtolower($ext);
173 8
            if (isset($mimeTypes[$ext])) {
174 8
                return $mimeTypes[$ext];
175
            }
176
        }
177
178 1
        return null;
179
    }
180
181
    /**
182
     * Determines the extensions by given MIME type.
183
     * This method will use a local map between extension names and MIME types.
184
     * @param string $mimeType file MIME type.
185
     * @param string $magicFile the path (or alias) of the file that contains all available MIME type information.
186
     * If this is not set, the file specified by [[mimeMagicFile]] will be used.
187
     * @return array the extensions corresponding to the specified MIME type
188
     */
189
    public static function getExtensionsByMimeType($mimeType, $magicFile = null)
190
    {
191
        $mimeTypes = static::loadMimeTypes($magicFile);
192
        return array_keys($mimeTypes, mb_strtolower($mimeType, 'UTF-8'), true);
193
    }
194
195
    private static $_mimeTypes = [];
196
197
    /**
198
     * Loads MIME types from the specified file.
199
     * @param string $magicFile the path (or alias) of the file that contains all available MIME type information.
200
     * If this is not set, the file specified by [[mimeMagicFile]] will be used.
201
     * @return array the mapping from file extensions to MIME types
202
     */
203 8
    protected static function loadMimeTypes($magicFile)
204
    {
205 8
        if ($magicFile === null) {
206 8
            $magicFile = static::$mimeMagicFile;
207
        }
208 8
        $magicFile = Yii::getAlias($magicFile);
209 8
        if (!isset(self::$_mimeTypes[$magicFile])) {
210 1
            self::$_mimeTypes[$magicFile] = require($magicFile);
211
        }
212 8
        return self::$_mimeTypes[$magicFile];
213
    }
214
215
    /**
216
     * Copies a whole directory as another one.
217
     * The files and sub-directories will also be copied over.
218
     * @param string $src the source directory
219
     * @param string $dst the destination directory
220
     * @param array $options options for directory copy. Valid options are:
221
     *
222
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
223
     * - fileMode:  integer, the permission to be set for newly copied files. Defaults to the current environment setting.
224
     * - filter: callback, a PHP callback that is called for each directory or file.
225
     *   The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered.
226
     *   The callback can return one of the following values:
227
     *
228
     *   * true: the directory or file will be copied (the "only" and "except" options will be ignored)
229
     *   * false: the directory or file will NOT be copied (the "only" and "except" options will be ignored)
230
     *   * null: the "only" and "except" options will determine whether the directory or file should be copied
231
     *
232
     * - only: array, list of patterns that the file paths should match if they want to be copied.
233
     *   A path matches a pattern if it contains the pattern string at its end.
234
     *   For example, '.php' matches all file paths ending with '.php'.
235
     *   Note, the '/' characters in a pattern matches both '/' and '\' in the paths.
236
     *   If a file path matches a pattern in both "only" and "except", it will NOT be copied.
237
     * - except: array, list of patterns that the files or directories should match if they want to be excluded from being copied.
238
     *   A path matches a pattern if it contains the pattern string at its end.
239
     *   Patterns ending with '/' apply to directory paths only, and patterns not ending with '/'
240
     *   apply to file paths only. For example, '/a/b' matches all file paths ending with '/a/b';
241
     *   and '.svn/' matches directory paths ending with '.svn'. Note, the '/' characters in a pattern matches
242
     *   both '/' and '\' in the paths.
243
     * - caseSensitive: boolean, whether patterns specified at "only" or "except" should be case sensitive. Defaults to true.
244
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
245
     * - beforeCopy: callback, a PHP callback that is called before copying each sub-directory or file.
246
     *   If the callback returns false, the copy operation for the sub-directory or file will be cancelled.
247
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or
248
     *   file to be copied from, while `$to` is the copy target.
249
     * - afterCopy: callback, a PHP callback that is called after each sub-directory or file is successfully copied.
250
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the sub-directory or
251
     *   file copied from, while `$to` is the copy target.
252
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating directories
253
     *   that do not contain files. This affects directories that do not contain files initially as well as directories that
254
     *   do not contain files at the target destination because files have been filtered via `only` or `except`.
255
     *   Defaults to true. This option is available since version 2.0.12. Before 2.0.12 empty directories are always copied.
256
     * @throws \yii\base\InvalidParamException if unable to open directory
257
     */
258 17
    public static function copyDirectory($src, $dst, $options = [])
259
    {
260 17
        $src = static::normalizePath($src);
261 17
        $dst = static::normalizePath($dst);
262
263 17
        if ($src === $dst || strpos($dst, $src . DIRECTORY_SEPARATOR) === 0) {
264 2
            throw new InvalidParamException('Trying to copy a directory to itself or a subdirectory.');
265
        }
266 15
        $dstExists = is_dir($dst);
267 15
        if (!$dstExists && (!isset($options['copyEmptyDirectories']) || $options['copyEmptyDirectories'])) {
268 6
            static::createDirectory($dst, isset($options['dirMode']) ? $options['dirMode'] : 0775, true);
269 6
            $dstExists = true;
270
        }
271
272 15
        $handle = opendir($src);
273 15
        if ($handle === false) {
274
            throw new InvalidParamException("Unable to open directory: $src");
275
        }
276 15
        if (!isset($options['basePath'])) {
277
            // this should be done only once
278 15
            $options['basePath'] = realpath($src);
279 15
            $options = static::normalizeOptions($options);
280
        }
281 15
        while (($file = readdir($handle)) !== false) {
282 15
            if ($file === '.' || $file === '..') {
283 15
                continue;
284
            }
285 13
            $from = $src . DIRECTORY_SEPARATOR . $file;
286 13
            $to = $dst . DIRECTORY_SEPARATOR . $file;
287 13
            if (static::filterPath($from, $options)) {
288 13
                if (isset($options['beforeCopy']) && !call_user_func($options['beforeCopy'], $from, $to)) {
289 2
                    continue;
290
                }
291 11
                if (is_file($from)) {
292 11
                    if (!$dstExists) {
293
                        // delay creation of destination directory until the first file is copied to avoid creating empty directories
294 5
                        static::createDirectory($dst, isset($options['dirMode']) ? $options['dirMode'] : 0775, true);
295 5
                        $dstExists = true;
296
                    }
297 11
                    copy($from, $to);
298 11
                    if (isset($options['fileMode'])) {
299 1
                        @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...
300
                    }
301
                } else {
302
                    // recursive copy, defaults to true
303 8
                    if (!isset($options['recursive']) || $options['recursive']) {
304 7
                        static::copyDirectory($from, $to, $options);
305
                    }
306
                }
307 11
                if (isset($options['afterCopy'])) {
308
                    call_user_func($options['afterCopy'], $from, $to);
309
                }
310
            }
311
        }
312 15
        closedir($handle);
313 15
    }
314
315
    /**
316
     * Removes a directory (and all its content) recursively.
317
     *
318
     * @param string $dir the directory to be deleted recursively.
319
     * @param array $options options for directory remove. Valid options are:
320
     *
321
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
322
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
323
     *   Only symlink would be removed in that default case.
324
     *
325
     * @throws ErrorException in case of failure
326
     */
327 104
    public static function removeDirectory($dir, $options = [])
328
    {
329 104
        if (!is_dir($dir)) {
330 8
            return;
331
        }
332 103
        if (isset($options['traverseSymlinks']) && $options['traverseSymlinks'] || !is_link($dir)) {
333 103
            if (!($handle = opendir($dir))) {
334
                return;
335
            }
336 103
            while (($file = readdir($handle)) !== false) {
337 103
                if ($file === '.' || $file === '..') {
338 103
                    continue;
339
                }
340 87
                $path = $dir . DIRECTORY_SEPARATOR . $file;
341 87
                if (is_dir($path)) {
342 50
                    static::removeDirectory($path, $options);
343
                } else {
344
                    try {
345 68
                        unlink($path);
346
                    } catch (ErrorException $e) {
347
                        if (DIRECTORY_SEPARATOR === '\\') {
348
                            // last resort measure for Windows
349
                            $lines = [];
350
                            exec("DEL /F/Q \"$path\"", $lines, $deleteError);
351
                        } else {
352
                            throw $e;
353
                        }
354
                    }
355
                }
356
            }
357 103
            closedir($handle);
358
        }
359 103
        if (is_link($dir)) {
360 2
            unlink($dir);
361
        } else {
362 103
            rmdir($dir);
363
        }
364 103
    }
365
366
    /**
367
     * Returns the files found under the specified directory and subdirectories.
368
     * @param string $dir the directory under which the files will be looked for.
369
     * @param array $options options for file searching. Valid options are:
370
     *
371
     * - `filter`: callback, a PHP callback that is called for each directory or file.
372
     *   The signature of the callback should be: `function ($path)`, where `$path` refers the full path to be filtered.
373
     *   The callback can return one of the following values:
374
     *
375
     *   * `true`: the directory or file will be returned (the `only` and `except` options will be ignored)
376
     *   * `false`: the directory or file will NOT be returned (the `only` and `except` options will be ignored)
377
     *   * `null`: the `only` and `except` options will determine whether the directory or file should be returned
378
     *
379
     * - `except`: array, list of patterns excluding from the results matching file or directory paths.
380
     *   Patterns ending with slash ('/') apply to directory paths only, and patterns not ending with '/'
381
     *   apply to file paths only. For example, '/a/b' matches all file paths ending with '/a/b';
382
     *   and `.svn/` matches directory paths ending with `.svn`.
383
     *   If the pattern does not contain a slash (`/`), it is treated as a shell glob pattern
384
     *   and checked for a match against the pathname relative to `$dir`.
385
     *   Otherwise, the pattern is treated as a shell glob suitable for consumption by `fnmatch(3)`
386
     *   with the `FNM_PATHNAME` flag: wildcards in the pattern will not match a `/` in the pathname.
387
     *   For example, `views/*.php` matches `views/index.php` but not `views/controller/index.php`.
388
     *   A leading slash matches the beginning of the pathname. For example, `/*.php` matches `index.php` but not `views/start/index.php`.
389
     *   An optional prefix `!` which negates the pattern; any matching file excluded by a previous pattern will become included again.
390
     *   If a negated pattern matches, this will override lower precedence patterns sources. Put a backslash (`\`) in front of the first `!`
391
     *   for patterns that begin with a literal `!`, for example, `\!important!.txt`.
392
     *   Note, the '/' characters in a pattern matches both '/' and '\' in the paths.
393
     * - `only`: array, list of patterns that the file paths should match if they are to be returned. Directory paths
394
     *   are not checked against them. Same pattern matching rules as in the `except` option are used.
395
     *   If a file path matches a pattern in both `only` and `except`, it will NOT be returned.
396
     * - `caseSensitive`: boolean, whether patterns specified at `only` or `except` should be case sensitive. Defaults to `true`.
397
     * - `recursive`: boolean, whether the files under the subdirectories should also be looked for. Defaults to `true`.
398
     * @return array files found under the directory, in no particular order. Ordering depends on the files system used.
399
     * @throws InvalidParamException if the dir is invalid.
400
     */
401 59
    public static function findFiles($dir, $options = [])
402
    {
403 59
        if (!is_dir($dir)) {
404
            throw new InvalidParamException("The dir argument must be a directory: $dir");
405
        }
406 59
        $dir = rtrim($dir, DIRECTORY_SEPARATOR);
407 59
        if (!isset($options['basePath'])) {
408
            // this should be done only once
409 59
            $options['basePath'] = realpath($dir);
410 59
            $options = static::normalizeOptions($options);
411
        }
412 59
        $list = [];
413 59
        $handle = opendir($dir);
414 59
        if ($handle === false) {
415
            throw new InvalidParamException("Unable to open directory: $dir");
416
        }
417 59
        while (($file = readdir($handle)) !== false) {
418 59
            if ($file === '.' || $file === '..') {
419 59
                continue;
420
            }
421 59
            $path = $dir . DIRECTORY_SEPARATOR . $file;
422 59
            if (static::filterPath($path, $options)) {
423 59
                if (is_file($path)) {
424 57
                    $list[] = $path;
425 19
                } elseif (is_dir($path) && (!isset($options['recursive']) || $options['recursive'])) {
426 18
                    $list = array_merge($list, static::findFiles($path, $options));
427
                }
428
            }
429
        }
430 59
        closedir($handle);
431
432 59
        return $list;
433
    }
434
435
    /**
436
     * Checks if the given file path satisfies the filtering options.
437
     * @param string $path the path of the file or directory to be checked
438
     * @param array $options the filtering options. See [[findFiles()]] for explanations of
439
     * the supported options.
440
     * @return bool whether the file or directory satisfies the filtering options.
441
     */
442 68
    public static function filterPath($path, $options)
443
    {
444 68
        if (isset($options['filter'])) {
445 1
            $result = call_user_func($options['filter'], $path);
446 1
            if (is_bool($result)) {
447 1
                return $result;
448
            }
449
        }
450
451 67
        if (empty($options['except']) && empty($options['only'])) {
452 23
            return true;
453
        }
454
455 48
        $path = str_replace('\\', '/', $path);
456
457 48
        if (!empty($options['except'])) {
458 31
            if (($except = self::lastExcludeMatchingFromList($options['basePath'], $path, $options['except'])) !== null) {
459 2
                return $except['flags'] & self::PATTERN_NEGATIVE;
460
            }
461
        }
462
463 48
        if (!empty($options['only']) && !is_dir($path)) {
464 47
            if (($except = self::lastExcludeMatchingFromList($options['basePath'], $path, $options['only'])) !== null) {
465
                // don't check PATTERN_NEGATIVE since those entries are not prefixed with !
466 45
                return true;
467
            }
468
469 17
            return false;
470
        }
471
472 17
        return true;
473
    }
474
475
    /**
476
     * Creates a new directory.
477
     *
478
     * This method is similar to the PHP `mkdir()` function except that
479
     * it uses `chmod()` to set the permission of the created directory
480
     * in order to avoid the impact of the `umask` setting.
481
     *
482
     * @param string $path path of the directory to be created.
483
     * @param int $mode the permission to be set for the created directory.
484
     * @param bool $recursive whether to create parent directories if they do not exist.
485
     * @return bool whether the directory is created successfully
486
     * @throws \yii\base\Exception if the directory could not be created (i.e. php error due to parallel changes)
487
     */
488 146
    public static function createDirectory($path, $mode = 0775, $recursive = true)
489
    {
490 146
        if (is_dir($path)) {
491 49
            return true;
492
        }
493 131
        $parentDir = dirname($path);
494
        // recurse if parent dir does not exist and we are not at the root of the file system.
495 131
        if ($recursive && !is_dir($parentDir) && $parentDir !== $path) {
496 5
            static::createDirectory($parentDir, $mode, true);
497
        }
498
        try {
499 131
            if (!mkdir($path, $mode)) {
500
                return false;
501
            }
502
        } catch (\Exception $e) {
503
            if (!is_dir($path)) {// https://github.com/yiisoft/yii2/issues/9288
504
                throw new \yii\base\Exception("Failed to create directory \"$path\": " . $e->getMessage(), $e->getCode(), $e);
505
            }
506
        }
507
        try {
508 131
            return chmod($path, $mode);
509
        } catch (\Exception $e) {
510
            throw new \yii\base\Exception("Failed to change permissions for directory \"$path\": " . $e->getMessage(), $e->getCode(), $e);
511
        }
512
    }
513
514
    /**
515
     * Performs a simple comparison of file or directory names.
516
     *
517
     * Based on match_basename() from dir.c of git 1.8.5.3 sources.
518
     *
519
     * @param string $baseName file or directory name to compare with the pattern
520
     * @param string $pattern the pattern that $baseName will be compared against
521
     * @param int|bool $firstWildcard location of first wildcard character in the $pattern
522
     * @param int $flags pattern flags
523
     * @return bool whether the name matches against pattern
524
     */
525 47
    private static function matchBasename($baseName, $pattern, $firstWildcard, $flags)
526
    {
527 47
        if ($firstWildcard === false) {
528 38
            if ($pattern === $baseName) {
529 7
                return true;
530
            }
531 38
        } elseif ($flags & self::PATTERN_ENDSWITH) {
532
            /* "*literal" matching against "fooliteral" */
533 38
            $n = StringHelper::byteLength($pattern);
534 38
            if (StringHelper::byteSubstr($pattern, 1, $n) === StringHelper::byteSubstr($baseName, -$n, $n)) {
535
                return true;
536
            }
537
        }
538
539 47
        $fnmatchFlags = 0;
540 47
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
541 1
            $fnmatchFlags |= FNM_CASEFOLD;
542
        }
543
544 47
        return fnmatch($pattern, $baseName, $fnmatchFlags);
545
    }
546
547
    /**
548
     * Compares a path part against a pattern with optional wildcards.
549
     *
550
     * Based on match_pathname() from dir.c of git 1.8.5.3 sources.
551
     *
552
     * @param string $path full path to compare
553
     * @param string $basePath base of path that will not be compared
554
     * @param string $pattern the pattern that path part will be compared against
555
     * @param int|bool $firstWildcard location of first wildcard character in the $pattern
556
     * @param int $flags pattern flags
557
     * @return bool whether the path part matches against pattern
558
     */
559 33
    private static function matchPathname($path, $basePath, $pattern, $firstWildcard, $flags)
560
    {
561
        // match with FNM_PATHNAME; the pattern has base implicitly in front of it.
562 33
        if (isset($pattern[0]) && $pattern[0] === '/') {
563 30
            $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern));
564 30
            if ($firstWildcard !== false && $firstWildcard !== 0) {
565
                $firstWildcard--;
566
            }
567
        }
568
569 33
        $namelen = StringHelper::byteLength($path) - (empty($basePath) ? 0 : StringHelper::byteLength($basePath) + 1);
570 33
        $name = StringHelper::byteSubstr($path, -$namelen, $namelen);
571
572 33
        if ($firstWildcard !== 0) {
573 33
            if ($firstWildcard === false) {
574 32
                $firstWildcard = StringHelper::byteLength($pattern);
575
            }
576
            // if the non-wildcard part is longer than the remaining pathname, surely it cannot match.
577 33
            if ($firstWildcard > $namelen) {
578 2
                return false;
579
            }
580
581 33
            if (strncmp($pattern, $name, $firstWildcard)) {
582 33
                return false;
583
            }
584 4
            $pattern = StringHelper::byteSubstr($pattern, $firstWildcard, StringHelper::byteLength($pattern));
585 4
            $name = StringHelper::byteSubstr($name, $firstWildcard, $namelen);
586
587
            // 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.
588 4
            if (empty($pattern) && empty($name)) {
589 3
                return true;
590
            }
591
        }
592
593 2
        $fnmatchFlags = FNM_PATHNAME;
594 2
        if ($flags & self::PATTERN_CASE_INSENSITIVE) {
595
            $fnmatchFlags |= FNM_CASEFOLD;
596
        }
597
598 2
        return fnmatch($pattern, $name, $fnmatchFlags);
599
    }
600
601
    /**
602
     * Scan the given exclude list in reverse to see whether pathname
603
     * should be ignored.  The first match (i.e. the last on the list), if
604
     * any, determines the fate.  Returns the element which
605
     * matched, or null for undecided.
606
     *
607
     * Based on last_exclude_matching_from_list() from dir.c of git 1.8.5.3 sources.
608
     *
609
     * @param string $basePath
610
     * @param string $path
611
     * @param array $excludes list of patterns to match $path against
612
     * @return string null or one of $excludes item as an array with keys: 'pattern', 'flags'
613
     * @throws InvalidParamException if any of the exclude patterns is not a string or an array with keys: pattern, flags, firstWildcard.
614
     */
615 48
    private static function lastExcludeMatchingFromList($basePath, $path, $excludes)
616
    {
617 48
        foreach (array_reverse($excludes) as $exclude) {
618 48
            if (is_string($exclude)) {
619
                $exclude = self::parseExcludePattern($exclude, false);
620
            }
621 48
            if (!isset($exclude['pattern']) || !isset($exclude['flags']) || !isset($exclude['firstWildcard'])) {
622
                throw new InvalidParamException('If exclude/include pattern is an array it must contain the pattern, flags and firstWildcard keys.');
623
            }
624 48
            if ($exclude['flags'] & self::PATTERN_MUSTBEDIR && !is_dir($path)) {
625
                continue;
626
            }
627
628 48
            if ($exclude['flags'] & self::PATTERN_NODIR) {
629 47
                if (self::matchBasename(basename($path), $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) {
630 45
                    return $exclude;
631
                }
632 46
                continue;
633
            }
634
635 33
            if (self::matchPathname($path, $basePath, $exclude['pattern'], $exclude['firstWildcard'], $exclude['flags'])) {
636 4
                return $exclude;
637
            }
638
        }
639
640 47
        return null;
641
    }
642
643
    /**
644
     * Processes the pattern, stripping special characters like / and ! from the beginning and settings flags instead.
645
     * @param string $pattern
646
     * @param bool $caseSensitive
647
     * @throws \yii\base\InvalidParamException
648
     * @return array with keys: (string) pattern, (int) flags, (int|bool) firstWildcard
649
     */
650 48
    private static function parseExcludePattern($pattern, $caseSensitive)
651
    {
652 48
        if (!is_string($pattern)) {
653
            throw new InvalidParamException('Exclude/include pattern must be a string.');
654
        }
655
656
        $result = [
657 48
            'pattern' => $pattern,
658 48
            'flags' => 0,
659
            'firstWildcard' => false,
660
        ];
661
662 48
        if (!$caseSensitive) {
663 1
            $result['flags'] |= self::PATTERN_CASE_INSENSITIVE;
664
        }
665
666 48
        if (!isset($pattern[0])) {
667
            return $result;
668
        }
669
670 48
        if ($pattern[0] === '!') {
671
            $result['flags'] |= self::PATTERN_NEGATIVE;
672
            $pattern = StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern));
673
        }
674 48
        if (StringHelper::byteLength($pattern) && StringHelper::byteSubstr($pattern, -1, 1) === '/') {
675
            $pattern = StringHelper::byteSubstr($pattern, 0, -1);
676
            $result['flags'] |= self::PATTERN_MUSTBEDIR;
677
        }
678 48
        if (strpos($pattern, '/') === false) {
679 47
            $result['flags'] |= self::PATTERN_NODIR;
680
        }
681 48
        $result['firstWildcard'] = self::firstWildcardInPattern($pattern);
682 48
        if ($pattern[0] === '*' && self::firstWildcardInPattern(StringHelper::byteSubstr($pattern, 1, StringHelper::byteLength($pattern))) === false) {
683 38
            $result['flags'] |= self::PATTERN_ENDSWITH;
684
        }
685 48
        $result['pattern'] = $pattern;
686
687 48
        return $result;
688
    }
689
690
    /**
691
     * Searches for the first wildcard character in the pattern.
692
     * @param string $pattern the pattern to search in
693
     * @return int|bool position of first wildcard character or false if not found
694
     */
695 48
    private static function firstWildcardInPattern($pattern)
696
    {
697 48
        $wildcards = ['*', '?', '[', '\\'];
698 48
        $wildcardSearch = function ($r, $c) use ($pattern) {
699 48
            $p = strpos($pattern, $c);
700
701 48
            return $r === false ? $p : ($p === false ? $r : min($r, $p));
702 48
        };
703
704 48
        return array_reduce($wildcards, $wildcardSearch, false);
705
    }
706
707
    /**
708
     * @param array $options raw options
709
     * @return array normalized options
710
     * @since 2.0.12
711
     */
712 70
    protected static function normalizeOptions(array $options)
713
    {
714 70
        if (!array_key_exists('caseSensitive', $options)) {
715 69
            $options['caseSensitive'] = true;
716
        }
717 70
        if (isset($options['except'])) {
718 31
            foreach ($options['except'] as $key => $value) {
719 31
                if (is_string($value)) {
720 31
                    $options['except'][$key] = self::parseExcludePattern($value, $options['caseSensitive']);
721
                }
722
            }
723
        }
724 70
        if (isset($options['only'])) {
725 47
            foreach ($options['only'] as $key => $value) {
726 47
                if (is_string($value)) {
727 47
                    $options['only'][$key] = self::parseExcludePattern($value, $options['caseSensitive']);
728
                }
729
            }
730
        }
731 70
        return $options;
732
    }
733
}
734