Passed
Push — master ( 8e5cfb...36bf35 )
by Sergei
05:23 queued 02:38
created

FileHelper::openFile()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 19
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 2.0023

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 9
c 1
b 0
f 0
nc 2
nop 4
dl 0
loc 19
ccs 11
cts 12
cp 0.9167
crap 2.0023
rs 9.9666
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Files;
6
7
use FilesystemIterator;
8
use InvalidArgumentException;
9
use LogicException;
10
use RecursiveDirectoryIterator;
11
use RecursiveIteratorIterator;
12
use RuntimeException;
13
use Yiisoft\Files\PathMatcher\PathMatcherInterface;
14
15
use function array_key_exists;
16
use function filemtime;
17
use function get_debug_type;
18
use function is_file;
19
use function is_string;
20
21
/**
22
 * Provides useful methods to manage files and directories.
23
 */
24
final class FileHelper
25
{
26
    /**
27
     * Opens a file or URL.
28
     *
29
     * This method is similar to the PHP {@see fopen()} function, except that it suppresses the {@see E_WARNING}
30
     * level error and throws the {@see RuntimeException} exception if it can't open the file.
31
     *
32
     * @param string $filename The file or URL.
33
     * @param string $mode The type of access.
34
     * @param bool $useIncludePath Whether to search for a file in the include path.
35
     * @param resource|null $context The stream context or `null`.
36
     *
37
     * @throws RuntimeException If the file could not be opened.
38
     *
39
     * @return resource The file pointer resource.
40
     *
41
     * @psalm-suppress PossiblyNullArgument
42
     */
43 3
    public static function openFile(string $filename, string $mode, bool $useIncludePath = false, $context = null)
44
    {
45
        /** @psalm-suppress InvalidArgument, MixedArgumentTypeCoercion */
46 3
        set_error_handler(static function (int $errorNumber, string $errorString) use ($filename): bool {
47 1
            throw new RuntimeException(
48 1
                sprintf('Failed to open a file "%s". ', $filename) . $errorString,
49 1
                $errorNumber
50 1
            );
51 3
        });
52
53 3
        $filePointer = fopen($filename, $mode, $useIncludePath, $context);
54
55 2
        restore_error_handler();
56
57 2
        if ($filePointer === false) {
58
            throw new RuntimeException(sprintf('Failed to open a file "%s". ', $filename));
59
        }
60
61 2
        return $filePointer;
62
    }
63
64
    /**
65
     * Ensures directory exists and has specific permissions.
66
     *
67
     * This method is similar to the PHP {@see mkdir()} function with some differences:
68
     *
69
     * - It does not fail if directory already exists.
70
     * - It uses {@see chmod()} to set the permission of the created directory in order to avoid the impact
71
     *   of the `umask` setting.
72
     * - It throws exceptions instead of returning false and emitting {@see E_WARNING}.
73
     *
74
     * @param string $path Path of the directory to be created.
75
     * @param int $mode The permission to be set for the created directory.
76
     */
77 67
    public static function ensureDirectory(string $path, int $mode = 0775): void
78
    {
79 67
        $path = self::normalizePath($path);
80
81 67
        if (!is_dir($path)) {
82 67
            set_error_handler(static function (int $errorNumber, string $errorString) use ($path): bool {
83
                // Handle race condition.
84
                // See https://github.com/kalessil/phpinspectionsea/blob/master/docs/probable-bugs.md#mkdir-race-condition
85 1
                if (!is_dir($path)) {
86 1
                    throw new RuntimeException(
87 1
                        sprintf('Failed to create directory "%s". ', $path) . $errorString,
88 1
                        $errorNumber
89 1
                    );
90
                }
91
                return true;
92 67
            });
93
94 67
            mkdir($path, $mode, true);
95
96 67
            restore_error_handler();
97
        }
98
99 67
        if (!chmod($path, $mode)) {
100
            throw new RuntimeException(sprintf('Unable to set mode "%s" for "%s".', $mode, $path));
101
        }
102
    }
103
104
    /**
105
     * Normalizes a file/directory path.
106
     *
107
     * The normalization does the following work:
108
     *
109
     * - Convert all directory separators into `/` (e.g. "\a/b\c" becomes "/a/b/c")
110
     * - Remove trailing directory separators (e.g. "/a/b/c/" becomes "/a/b/c")
111
     * - Turn multiple consecutive slashes into a single one (e.g. "/a///b/c" becomes "/a/b/c")
112
     * - Remove ".." and "." based on their meanings (e.g. "/a/./b/../c" becomes "/a/c")
113
     *
114
     * @param string $path The file/directory path to be normalized.
115
     *
116
     * @return string The normalized file/directory path.
117
     */
118 67
    public static function normalizePath(string $path): string
119
    {
120 67
        $isWindowsShare = str_starts_with($path, '\\\\');
121
122 67
        if ($isWindowsShare) {
123 1
            $path = substr($path, 2);
124
        }
125
126 67
        $path = rtrim(strtr($path, '/\\', '//'), '/');
127
128 67
        if (!str_contains('/' . $path, '/.') && !str_contains($path, '//')) {
129 67
            return $isWindowsShare ? "\\\\$path" : $path;
130
        }
131
132 1
        $parts = [];
133
134 1
        foreach (explode('/', $path) as $part) {
135 1
            if ($part === '..' && !empty($parts) && end($parts) !== '..') {
136 1
                array_pop($parts);
137 1
            } elseif ($part !== '.' && ($part !== '' || empty($parts))) {
138 1
                $parts[] = $part;
139
            }
140
        }
141
142 1
        $path = implode('/', $parts);
143
144 1
        if ($isWindowsShare) {
145 1
            $path = '\\\\' . $path;
146
        }
147
148 1
        return $path === '' ? '.' : $path;
149
    }
150
151
    /**
152
     * Removes a directory (and all its content) recursively. Does nothing if directory does not exist.
153
     *
154
     * @param string $directory The directory to be deleted recursively.
155
     * @param array $options Options for directory remove. Valid options are:
156
     *
157
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
158
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
159
     *   Only symlink would be removed in that default case.
160
     *
161
     * @psalm-param array{
162
     *     traverseSymlinks?:bool,
163
     * } $options
164
     *
165
     * @throw RuntimeException When unable to remove directory.
166
     */
167 67
    public static function removeDirectory(string $directory, array $options = []): void
168
    {
169 67
        if (!file_exists($directory)) {
170 1
            return;
171
        }
172
173 67
        self::clearDirectory(
174 67
            $directory,
175 67
            ['traverseSymlinks' => $options['traverseSymlinks'] ?? false]
176 67
        );
177
178 67
        self::removeLinkOrEmptyDirectory($directory);
179
    }
180
181
    /**
182
     * Clears all directory content.
183
     *
184
     * @param string $directory The directory to be cleared.
185
     * @param array $options Options for directory clear. Valid options are:
186
     *
187
     * - traverseSymlinks: boolean, whether symlinks to the directories should be traversed too.
188
     *   Defaults to `false`, meaning the content of the symlinked directory would not be deleted.
189
     *   Only symlink would be removed in that default case.
190
     * - filter: a filter to apply while deleting files. It should be an instance of {@see PathMatcherInterface}.
191
     *
192
     * @throws RuntimeException if unable to open directory.
193
     *
194
     * @psalm-param array{
195
     *     traverseSymlinks?:bool,
196
     *     filter?:PathMatcherInterface
197
     * } $options
198
     */
199 67
    public static function clearDirectory(string $directory, array $options = []): void
200
    {
201 67
        $filter = self::getFilter($options);
202 67
        $handle = self::openDirectory($directory);
203 67
        if (!empty($options['traverseSymlinks']) || !is_link($directory)) {
204 67
            while (($file = readdir($handle)) !== false) {
205 67
                if ($file === '.' || $file === '..') {
206 67
                    continue;
207
                }
208
209 48
                $path = $directory . '/' . $file;
210
211 48
                if ($filter === null || $filter->match($path)) {
212 48
                    if (is_dir($path)) {
213 47
                        self::clearDirectory($path, $options);
214 47
                        if (is_link($path) || self::isEmptyDirectory($path)) {
215 47
                            self::removeLinkOrEmptyDirectory($path);
216
                        }
217
                    } else {
218 38
                        self::unlink($path);
219
                    }
220
                }
221
            }
222 67
            closedir($handle);
223
        }
224
    }
225
226
    /**
227
     * Removes a file or symlink in a cross-platform way.
228
     *
229
     * @param string $path Path to unlink.
230
     */
231 42
    public static function unlink(string $path): void
232
    {
233
        /** @psalm-suppress InvalidArgument, MixedArgumentTypeCoercion */
234 42
        set_error_handler(static function (int $errorNumber, string $errorString) use ($path): bool {
235 1
            throw new RuntimeException(
236 1
                sprintf('Failed to unlink "%s". ', $path) . $errorString,
237 1
                $errorNumber
238 1
            );
239 42
        });
240
241 42
        $isWindows = DIRECTORY_SEPARATOR === '\\';
242
243 42
        if ($isWindows) {
244
            if (is_link($path)) {
245
                try {
246
                    unlink($path);
247
                } catch (RuntimeException) {
248
                    rmdir($path);
249
                }
250
            } else {
251
                if (file_exists($path) && !is_writable($path)) {
252
                    chmod($path, 0777);
253
                }
254
                unlink($path);
255
            }
256
        } else {
257 42
            unlink($path);
258
        }
259 41
        restore_error_handler();
260
    }
261
262
    /**
263
     * Tells whether the path is an empty directory.
264
     *
265
     * @param string $path Path to check for being an empty directory.
266
     */
267 47
    public static function isEmptyDirectory(string $path): bool
268
    {
269 47
        if (!is_dir($path)) {
270 1
            return false;
271
        }
272
273 47
        return !(new FilesystemIterator($path))->valid();
274
    }
275
276
    /**
277
     * Copies a whole directory as another one.
278
     *
279
     * The files and subdirectories will also be copied over.
280
     *
281
     * @param string $source The source directory.
282
     * @param string $destination The destination directory.
283
     * @param array $options Options for directory copy. Valid options are:
284
     *
285
     * - dirMode: integer, the permission to be set for newly copied directories. Defaults to 0775.
286
     * - fileMode: integer, the permission to be set for newly copied files. Defaults to the current environment
287
     *   setting.
288
     * - filter: a filter to apply while copying files. It should be an instance of {@see PathMatcherInterface}.
289
     * - recursive: boolean, whether the files under the subdirectories should also be copied. Defaults to true.
290
     * - beforeCopy: callback, a PHP callback that is called before copying each subdirectory or file. If the callback
291
     *   returns false, the copy operation for the subdirectory or file will be cancelled. The signature of the
292
     *   callback should be: `function ($from, $to)`, where `$from` is the subdirectory or file to be copied from,
293
     *   while `$to` is the copy target.
294
     * - afterCopy: callback, a PHP callback that is called after each subdirectory or file is successfully copied.
295
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the subdirectory or file
296
     *   copied from, while `$to` is the copy target.
297
     * - copyEmptyDirectories: boolean, whether to copy empty directories. Set this to false to avoid creating
298
     *   directories that do not contain files. This affects directories that do not contain files initially as well as
299
     *   directories that do not contain files at the target destination because files have been filtered via `only` or
300
     *   `except`. Defaults to true.
301
     *
302
     * @throws RuntimeException if unable to open directory
303
     *
304
     * @psalm-param array{
305
     *   dirMode?: int,
306
     *   fileMode?: int,
307
     *   filter?: PathMatcherInterface,
308
     *   recursive?: bool,
309
     *   beforeCopy?: callable,
310
     *   afterCopy?: callable,
311
     *   copyEmptyDirectories?: bool,
312
     * } $options
313
     */
314 18
    public static function copyDirectory(string $source, string $destination, array $options = []): void
315
    {
316 18
        $filter = self::getFilter($options);
317 18
        $afterCopy = $options['afterCopy'] ?? null;
318 18
        $beforeCopy = $options['beforeCopy'] ?? null;
319 18
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
320
321 18
        if (!isset($options['dirMode'])) {
322 17
            $options['dirMode'] = 0755;
323
        }
324
325 18
        $source = self::normalizePath($source);
326 18
        $destination = self::normalizePath($destination);
327 18
        $copyEmptyDirectories = !array_key_exists('copyEmptyDirectories', $options) || $options['copyEmptyDirectories'];
328
329 18
        self::assertNotSelfDirectory($source, $destination);
330
331 16
        if (self::processCallback($beforeCopy, $source, $destination) === false) {
332 1
            return;
333
        }
334
335 14
        if ($copyEmptyDirectories && !is_dir($destination)) {
336 10
            self::ensureDirectory($destination, $options['dirMode']);
337
        }
338
339 14
        $handle = self::openDirectory($source);
340
341 13
        if (!array_key_exists('basePath', $options)) {
342 13
            $options['basePath'] = realpath($source);
343
        }
344
345 13
        while (($file = readdir($handle)) !== false) {
346 13
            if ($file === '.' || $file === '..') {
347 13
                continue;
348
            }
349
350 11
            $from = $source . '/' . $file;
351 11
            $to = $destination . '/' . $file;
352
353 11
            if ($filter === null || $filter->match($from)) {
354 11
                if (is_file($from)) {
355 11
                    self::copyFile($from, $to, $options);
356 6
                } elseif ($recursive) {
357 5
                    self::copyDirectory($from, $to, $options);
358
                }
359
            }
360
        }
361
362 13
        closedir($handle);
363
364 13
        self::processCallback($afterCopy, $source, $destination);
365
    }
366
367
    /**
368
     * Copies files with some options.
369
     *
370
     * - dirMode: integer or null, the permission to be set for newly copied directories. Defaults to null.
371
     *   When null - directory will be not created
372
     * - fileMode: integer, the permission to be set for newly copied files. Defaults to the current environment
373
     *   setting.
374
     * - beforeCopy: callback, a PHP callback that is called before copying file. If the callback
375
     *   returns false, the copy operation for file will be cancelled. The signature of the
376
     *   callback should be: `function ($from, $to)`, where `$from` is the file to be copied from,
377
     *   while `$to` is the copy target.
378
     * - afterCopy: callback, a PHP callback that is called after file if successfully copied.
379
     *   The signature of the callback should be: `function ($from, $to)`, where `$from` is the file
380
     *   copied from, while `$to` is the copy target.
381
     *
382
     * @param string $source The source file
383
     * @param string $destination The destination filename
384
     * @param array $options
385
     *
386
     * @psalm-param array{
387
     *   dirMode?: int,
388
     *   fileMode?: int,
389
     *   beforeCopy?: callable,
390
     *   afterCopy?: callable,
391
     * } $options
392
     */
393 14
    public static function copyFile(string $source, string $destination, array $options = []): void
394
    {
395 14
        if (!is_file($source)) {
396 1
            throw new InvalidArgumentException('Argument $source must be an existing file.');
397
        }
398
399 13
        $dirname = dirname($destination);
400 13
        $dirMode = $options['dirMode'] ?? 0755;
401 13
        $fileMode = $options['fileMode'] ?? null;
402 13
        $afterCopy = $options['afterCopy'] ?? null;
403 13
        $beforeCopy = $options['beforeCopy'] ?? null;
404
405 13
        if (self::processCallback($beforeCopy, $source, $destination) === false) {
406 1
            return;
407
        }
408
409 13
        if (!is_dir($dirname)) {
410 4
            self::ensureDirectory($dirname, $dirMode);
411
        }
412
413 13
        if (!copy($source, $destination)) {
414
            throw new RuntimeException('Failed to copy the file.');
415
        }
416
417 13
        if ($fileMode !== null && !chmod($destination, $fileMode)) {
418
            throw new RuntimeException(sprintf('Unable to set mode "%s" for "%s".', $fileMode, $destination));
419
        }
420
421 13
        self::processCallback($afterCopy, $source, $destination);
422
    }
423
424
    /**
425
     * @throws InvalidArgumentException
426
     */
427 17
    private static function processCallback(?callable $callback, mixed ...$arguments): mixed
428
    {
429 17
        return $callback ? $callback(...$arguments) : null;
430
    }
431
432 67
    private static function getFilter(array $options): ?PathMatcherInterface
433
    {
434 67
        if (!array_key_exists('filter', $options)) {
435 67
            return null;
436
        }
437
438 9
        if (!$options['filter'] instanceof PathMatcherInterface) {
439 2
            $type = get_debug_type($options['filter']);
440 2
            throw new InvalidArgumentException(
441 2
                sprintf('Filter should be an instance of PathMatcherInterface, %s given.', $type)
442 2
            );
443
        }
444
445 7
        return $options['filter'];
446
    }
447
448
    /**
449
     * Assert that destination is not within the source directory.
450
     *
451
     * @param string $source Path to source.
452
     * @param string $destination Path to destination.
453
     *
454
     * @throws InvalidArgumentException
455
     */
456 18
    private static function assertNotSelfDirectory(string $source, string $destination): void
457
    {
458 18
        if ($source === $destination || str_starts_with($destination, $source . '/')) {
459 2
            throw new InvalidArgumentException('Trying to copy a directory to itself or a subdirectory.');
460
        }
461
    }
462
463
    /**
464
     * Open directory handle.
465
     *
466
     * @param string $directory Path to directory.
467
     *
468
     * @throws RuntimeException if unable to open directory.
469
     * @throws InvalidArgumentException if argument is not a directory.
470
     *
471
     * @return resource
472
     */
473 67
    private static function openDirectory(string $directory)
474
    {
475 67
        if (!file_exists($directory)) {
476 3
            throw new InvalidArgumentException("\"$directory\" does not exist.");
477
        }
478
479 67
        if (!is_dir($directory)) {
480 1
            throw new InvalidArgumentException("\"$directory\" is not a directory.");
481
        }
482
483
        /** @psalm-suppress InvalidArgument, MixedArgumentTypeCoercion */
484 67
        set_error_handler(static function (int $errorNumber, string $errorString) use ($directory): bool {
485
            throw new RuntimeException(
486
                sprintf('Unable to open directory "%s". ', $directory) . $errorString,
487
                $errorNumber
488
            );
489 67
        });
490
491 67
        $handle = opendir($directory);
492
493 67
        if ($handle === false) {
494
            throw new RuntimeException(sprintf('Unable to open directory "%s". ', $directory));
495
        }
496
497 67
        restore_error_handler();
498
499 67
        return $handle;
500
    }
501
502
    /**
503
     * Returns the last modification time for the given paths.
504
     *
505
     * If the path is a directory, any nested files/directories will be checked as well.
506
     *
507
     * @param RecursiveDirectoryIterator[]|string[] $paths The directories to be checked.
508
     *
509
     * @throws LogicException If path is not set.
510
     *
511
     * @return int|null Unix timestamp representing the last modification time.
512
     */
513 3
    public static function lastModifiedTime(string|RecursiveDirectoryIterator ...$paths): ?int
514
    {
515 3
        if (empty($paths)) {
516 1
            throw new LogicException('Path is required.');
517
        }
518
519 2
        $time = null;
520
521 2
        foreach ($paths as $path) {
522 2
            if (is_string($path)) {
523 2
                $timestamp = self::modifiedTime($path);
524
525 2
                if ($timestamp > $time) {
526 2
                    $time = $timestamp;
527
                }
528
529 2
                if (is_file($path)) {
530 1
                    continue;
531
                }
532
533 2
                $path = new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS);
534
            }
535
536
            /** @var iterable<string, string> $iterator */
537 2
            $iterator = new RecursiveIteratorIterator(
538 2
                $path,
539 2
                RecursiveIteratorIterator::SELF_FIRST
540 2
            );
541
542 2
            foreach ($iterator as $path => $_info) {
0 ignored issues
show
Comprehensibility Bug introduced by
$path is overwriting a variable from outer foreach loop.
Loading history...
543 2
                $timestamp = self::modifiedTime($path);
544
545 2
                if ($timestamp > $time) {
546 1
                    $time = $timestamp;
547
                }
548
            }
549
        }
550
551 2
        return $time;
552
    }
553
554 2
    private static function modifiedTime(string $path): ?int
555
    {
556 2
        if (false !== $timestamp = filemtime($path)) {
557 2
            return $timestamp;
558
        }
559
560
        return null;
561
    }
562
563
    /**
564
     * Returns the directories found under the specified directory and subdirectories.
565
     *
566
     * @param string $directory The directory under which the files will be looked for.
567
     * @param array $options Options for directory searching. Valid options are:
568
     *
569
     * - filter: a filter to apply while looked directories. It should be an instance of {@see PathMatcherInterface}.
570
     * - recursive: boolean, whether the subdirectories should also be looked for. Defaults to `true`.
571
     *
572
     * @psalm-param array{
573
     *   filter?: PathMatcherInterface,
574
     *   recursive?: bool,
575
     * } $options
576
     *
577
     * @throws InvalidArgumentException If the directory is invalid.
578
     *
579
     * @return string[] Directories found under the directory specified, in no particular order.
580
     * Ordering depends on the file system used.
581
     */
582 6
    public static function findDirectories(string $directory, array $options = []): array
583
    {
584 6
        $filter = self::getFilter($options);
585 5
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
586 5
        $directory = self::normalizePath($directory);
587
588 5
        $result = [];
589
590 5
        $handle = self::openDirectory($directory);
591 4
        while (false !== $file = readdir($handle)) {
592 4
            if ($file === '.' || $file === '..') {
593 4
                continue;
594
            }
595
596 4
            $path = $directory . '/' . $file;
597 4
            if (is_file($path)) {
598 3
                continue;
599
            }
600
601 4
            if ($filter === null || $filter->match($path)) {
602 4
                $result[] = $path;
603
            }
604
605 4
            if ($recursive) {
606 3
                $result = array_merge($result, self::findDirectories($path, $options));
607
            }
608
        }
609 4
        closedir($handle);
610
611 4
        return $result;
612
    }
613
614
    /**
615
     * Returns the files found under the specified directory and subdirectories.
616
     *
617
     * @param string $directory The directory under which the files will be looked for.
618
     * @param array $options Options for file searching. Valid options are:
619
     *
620
     * - filter: a filter to apply while looked files. It should be an instance of {@see PathMatcherInterface}.
621
     * - recursive: boolean, whether the files under the subdirectories should also be looked for. Defaults to `true`.
622
     *
623
     * @psalm-param array{
624
     *   filter?: PathMatcherInterface,
625
     *   recursive?: bool,
626
     * } $options
627
     *
628
     * @throws InvalidArgumentException If the directory is invalid.
629
     *
630
     * @return string[] Files found under the directory specified, in no particular order.
631
     * Ordering depends on the files system used.
632
     */
633 6
    public static function findFiles(string $directory, array $options = []): array
634
    {
635 6
        $filter = self::getFilter($options);
636 5
        $recursive = !array_key_exists('recursive', $options) || $options['recursive'];
637
638 5
        $directory = self::normalizePath($directory);
639
640 5
        $result = [];
641
642 5
        $handle = self::openDirectory($directory);
643 4
        while (false !== $file = readdir($handle)) {
644 4
            if ($file === '.' || $file === '..') {
645 4
                continue;
646
            }
647
648 4
            $path = $directory . '/' . $file;
649
650 4
            if (is_file($path)) {
651 4
                if ($filter === null || $filter->match($path)) {
652 4
                    $result[] = $path;
653
                }
654 4
                continue;
655
            }
656
657 4
            if ($recursive) {
658 3
                $result = array_merge($result, self::findFiles($path, $options));
659
            }
660
        }
661 4
        closedir($handle);
662
663 4
        return $result;
664
    }
665
666
    /**
667
     * Removes a link or an empty directory.
668
     *
669
     * @param string $directory The empty directory or the link to be deleted.
670
     *
671
     * @throw RuntimeException When unable to remove directory or link.
672
     */
673 67
    private static function removeLinkOrEmptyDirectory(string $directory): void
674
    {
675 67
        if (is_link($directory)) {
676 3
            self::unlink($directory);
677
        } else {
678 67
            set_error_handler(static function (int $errorNumber, string $errorString) use ($directory): bool {
679
                throw new RuntimeException(
680
                    sprintf('Failed to remove directory "%s". ', $directory) . $errorString,
681
                    $errorNumber
682
                );
683 67
            });
684
685 67
            rmdir($directory);
686
687 67
            restore_error_handler();
688
        }
689
    }
690
}
691