PHPで再帰的にrmdir(ディレクトリー削除)の考察

## はじめに

ファイル削除は `unlink` で行い、ディレクトリー削除は、 `rmdir` で行う。

`rmdir` は、ディレクトリーが空でない場合、失敗してしまうので、中身のファイル群を削除してから、
ディレクトリー削除を実行することになる。

-   [PHP: unlink - Manual](http://php.net/manual/ja/function.unlink.php)
-   [PHP: rmdir - Manual](http://php.net/manual/ja/function.rmdir.php)

## とりあえず実装してみる(問題あるかも版)

### \[実装1\] よく見るやつ版

```php
<?php
/// Linux 環境でディレクトリーへのシンボリックリンクを含む場合、失敗する。
/// Windows 環境で存在しないディレクトリーへのリンクを含む場合、失敗する。
/// Windows 環境で <JUNCTION> を含む場合、その中身もリストされる。(バグ？)
/// 引数がディレクトリーへのリンクの場合、リンク先のファイルを削除する。(さらに Linux 環境では最後のrmdirで失敗)
function removeDirectory($directory)
{
    $result = true;
    foreach(new \RecursiveIteratorIterator(
        new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS),
        \RecursiveIteratorIterator::CHILD_FIRST
    ) as $file) {
        if ($file->isDir()) {
            $result &= @rmdir($file->getPathname());
        } else {
            $result &= @unlink($file->getPathname());
        }
    }
    return $result && @rmdir($directory);
}
```

### \[実装2\] 外部プログラム版

```php
<?php
// 外部プログラムを利用する版(環境依存)。
// ディレクトリーを指すシンボリックリンクの場合、リンク先のファイル群が削除される場合がある
// (パスの末尾のセパレーターの有無とか、環境によって動作が異なる)
function removeDirectorySystem($directory)
{
    //if (mb_substr_count(realpath($directory), DIRECTORY_SEPARATOR, $encoding) < 3) {
    //    throw new \UnexpectedValueException(__FUNCTION__."({$directory}) Forbidden shallow directory");
    //}
    if (defined('PHP_WINDOWS_VERSION_BUILD')) { //< PHP 5.3.0 or later
        $command = 'rd /s /q '.escapeshellarg(realpath($directory)).' >nul 2>&1';
    } else {
        $command = 'rm -rf --no-preserve-root '.escapeshellarg($directory).' >/dev/null 2>&1';
    }
    $result = ((system($command, $returnVar) !== false) && ($returnVar === 0));
    clearstatcache();
    return $result || !file_exists($directory);
}
```

### 問題点

基本的に、シンボリックリンクで問題となる。
削除失敗ならまだマシ？だけど、リンク先のファイル群が削除されるのは避けたい。
(Linuxだけなら、`is_link()` で先に判定すれば良さそうだけど、Windowsのリンクに対応するには少し工夫が必要そう。)

## 既存の実装を参考にする

| 種別           | プロジェクト                                                                                                     | 関数                               | ディレクトリー走査                   |
|----------------|------------------------------------------------------------------------------------------------------------------|------------------------------------|--------------------------------------|
| フレームワーク | [Laravel](https://github.com/laravel/framework/blob/master/src/Illuminate/Filesystem/Filesystem.php)             | `Filesystem::deleteDirectory()`    | `FilesystemIterator`                 |
| フレームワーク | [Symfony/Filesystem](https://github.com/symfony/filesystem/blob/master/Filesystem.php)                           | `Filesystem::remove()`             | `FilesystemIterator`                 |
| フレームワーク | [Yii2](https://github.com/yiisoft/yii2/blob/master/framework/helpers/BaseFileHelper.php)                         | `BaseFileHelper::removeDirectory()`| `opendir`                            |
| フレームワーク | [CodeIgniter](https://github.com/bcit-ci/CodeIgniter/blob/develop/system/helpers/file_helper.php)                | `delete_files()`                   | `opendir`                            |
| フレームワーク | [CakePHP](https://github.com/cakephp/cakephp/blob/master/src/Filesystem/Folder.php)                              | `Folder::delete()`                 | `RecursiveDirectoryIterator`         |
| フレームワーク | [zend-cache](https://github.com/zendframework/zend-cache/blob/master/src/Storage/Adapter/Filesystem.php)         | `Filesystem::rmDir()`              | `glob`                               |
| フレームワーク | [Nette/Utility](https://github.com/nette/utils/blob/master/src/Utils/FileSystem.php)                             | `FileSystem::delete()`             | `FilesystemIterator`                 |
| CMS            | [WordPress](https://github.com/WordPress/WordPress/blob/master/wp-admin/includes/class-wp-filesystem-direct.php) | `WP_Filesystem_Direct::delete()`   | `dir`                                |
| CMS            | [Piwik](https://github.com/piwik/piwik/blob/master/core/Filesystem.php)                                          | `Filesystem::unlinkRecursive()`    | `opendir`                            |
| CMS            | [Grav](https://github.com/getgrav/grav/blob/master/system/src/Grav/Common/Filesystem/Folder.php)                 | `Folder::doDelete()`               | `scandir`                            |
| CMS            | [Anchor CMS](https://github.com/anchorcms/anchor-cms/blob/master/anchor/helpers.php)                             | `delTree()`                        | `scandir`                            |
| CMS            | [Drupal](https://github.com/drupal/drupal/blob/8.4.x/core/includes/file.inc)                                     | `file_unmanaged_delete_recursive()`| `dir`                                |
| CMS            | [EC-CUBE](https://github.com/EC-CUBE/ec-cube)                                                                    |                                    | `Symfony/Filesystem`                 |
| CMS            | [Zen Cart](https://github.com/zencart/zencart/tree/v160/admin/includes/functions/functions_helpers.php)          | `zen_remove_file()`                | `dir`                                |
| ライブラリー   | [League\Flysystem](https://github.com/thephpleague/flysystem/blob/master/src/Adapter/Local.php)                  | `Local::deleteDir()`               | `RecursiveDirectoryIterator`         |
| ライブラリー   | [Gaufrette](https://github.com/KnpLabs/Gaufrette/blob/master/src/Gaufrette/Adapter/Local.php)                    | `Local::delete()`                  | `RecursiveDirectoryIterator`         |
| ライブラリー   | [util.php](https://github.com/brandonwamboldt/utilphp/blob/master/src/utilphp/util.php)                          | `util::rmdir()`                    | `scandir`                            |
| その他         | [php-src(phar)](https://github.com/php/php-src/blob/master/ext/phar/shortarc.php)                                | `Extract_Phar::_removeTmpFiles()`  | `glob`                               |
| その他         | [Composer](https://github.com/composer/composer/blob/master/src/Composer/Util/Filesystem.php)                    | `Filesystem::remove()`             | `RecursiveDirectoryIterator`         |
| その他         | [phpFastCache](https://github.com/PHPSocialNetwork/phpfastcache/blob/final/src/phpFastCache/Util/Directory.php)  | `Directory::rrmdir()`              | `RecursiveDirectoryIterator`         |
| その他         | [Doctrine/Cache](https://github.com/doctrine/cache/blob/master/lib/Doctrine/Common/Cache/FileCache.php)          | `FileCache::doFlush()`             | `RecursiveDirectoryIterator`         |
| その他         | [Codeception](https://github.com/Codeception/Codeception/blob/master/src/Codeception/Util/FileSystem.php)        | `FileSystem::deleteDir()`          | `scandir`                            |
| その他         | [Behat](https://github.com/Behat/Behat/blob/master/features/bootstrap/FeatureContext.php)                        | `FeatureContext::clearDirectory()` | `scandir`                            |
| その他         | [Phing](https://github.com/phingofficial/phing/blob/master/classes/phing/system/io/FileSystem.php)               | `FileSystem::rmdir()`              | `opendir`                            |
| その他         | [Rocketeer](https://github.com/rocketeers/rocketeer)                                                             |                                    | `League\Flysystem`                   |

## Issues(ピックアップ)

-   https://bugs.php.net/bug.php?id=36365 (PHP5.6以前のWindowsの`scandir`で、65535以上のファイルで問題)
-   https://bugs.php.net/bug.php?id=52176 (Windowsでディレクトリーを指すシンボリックリンクが`unlink`で削除できない(仕様))
-   https://bugs.php.net/bug.php?id=54987 (PHP5.4.3以前のWindowsの`rmdir`はstatcacheをクリアしない)
-   https://bugs.php.net/bug.php?id=55468 (`RecursiveDirectoryIterator` Windowsでのファイルハンドルの問題)
-   https://bugs.php.net/bug.php?id=73884 (junction / symlinkd で `is_dir()` が false を返す)
-   https://github.com/composer/composer/issues/3074 (Windowsでのファイルロックの問題)
-   https://github.com/composer/composer/issues/4009 (`RecursiveDirectoryIterator` 作成時のPHPのキャッシュ問題)
-   https://github.com/composer/composer/issues/4955 (`<JUNCTION>` の内容が削除される)
-   https://github.com/symfony/symfony/issues/4429 (Windowsでディレクトリーを指すシンボリックリンクが`unlink`で削除できない)
-   https://github.com/symfony/symfony/pull/18324 (mklink for symlinks/junctions)
-   https://github.com/thephpleague/flysystem/issues/599 (symlinks サポート)

## Windowsのリンク(HARDLINK, SYMLINK, SYMLINKD, JUNCTION)

PHP7.1.9 on Windows

|対象                  |file_exists|is_file|is_dir|is_link|getType<br>(SplFileInfo)|削除関数   |
|----------------------|:---------:|:-----:|:----:|:-----:|------------------------|-----------|
|file_real.txt         |○         |○     |×    |×     |file                    |`unlink`   |
|dir_real              |○         |×     |○    |×     |dir                     |`rmdir`    |
|file_HARDLINK         |○         |○     |×    |×     |file                    |`unlink`   |
|file_SYMLINK          |○         |○     |×    |○     |link                    |`unlink`   |
|file_SYMLINK_broken   |×         |×     |×    |○     |link                    |`unlink`   |
|dir_SYMLINKD          |○         |×     |○    |○     |link                    |`rmdir`    |
|dir_SYMLINKD_broken   |×         |×     |×    |○     |link                    |`rmdir`    |
|dir_JUNCTION          |○         |×     |○    |×     |unknown                 |`rmdir`    |
|dir_JUNCTION_broken   |×         |×     |×    |×     |unknown                 |`rmdir`    |

-   シンボリックリンク(symlinks)の場合、 `file_exists`, `is_file`, `is_dir` は、リンク先の判定を行う。
-   Windows 環境で、 `RecursiveDirectoryIterator` を使用すると、 `<JUNCTION>` をディレクトリーと見做し、その中身もリストされる。(バグ？)
-   `is_link` と `is_dir` だけでは、削除方法の異なる、壊れた`<SYMLINK>`, `<SYMLINKD>`の区分けが出来ない。
-   壊れた `<JUNCTION>` は、`file_exists`, `is_file`, `is_dir`, `is_link` 全て失敗する。

## 再度、実装してみる

### \[実装3\] シンボリックリンクを意識した版

```php
<?php declare(strict_types=1);
/// シンボリックリンクはそのものを削除し、リンク先は削除しない
/// @param string $path ファイルかディレクトリーかリンクへのパス
function removeFileRecursive(string $path) : bool
{
    // See https://github.com/composer/composer/issues/4955
    $isJunction = function ($junction) {
        if ('\\' !== DIRECTORY_SEPARATOR) {
            return false;
        }
        // https://msdn.microsoft.com/en-us/library/14h5k7ff.aspx
        // #define	_S_IFDIR	0x4000
        // #define	_S_IFREG	0x8000
        $stat = @lstat($junction);
        return ($stat !== false) && !($stat['mode'] & 0xC000);
    };
    clearstatcache(true, $path);
    if (is_file($path)) {
        return @unlink($path);
    } elseif (is_link($path)) {
        // See https://bugs.php.net/52176
        return (@unlink($path) || ('\\' === DIRECTORY_SEPARATOR && @rmdir($path)));
    } elseif ($isJunction($path)) {
        return @rmdir($path);
    } elseif (is_dir($path)) {
        $result = true;
        foreach(new \FilesystemIterator($path) as $file) {
            if (!removeFileRecursive($file->getPathname())) {
                $result = false;
            }
        }
        return $result && @rmdir($path);
    }
    throw new \UnexpectedValueException(__FUNCTION__."({$path}) No such file or directory");
}
```

## おまけ

Windowsのリンク(HARDLINK, SYMLINK, SYMLINKD, JUNCTION) 表作成スクリプト

-   **管理者権限のコマンドプロンプトでのみ有効**
-   参考: https://technet.microsoft.com/ja-jp/library/cc753194(v=ws.10).aspx

```php
<?php
$root = '.\\test\\';
function mklink($command)
{
    if (!((system("mklink {$command} >nul 2>&1", $returnVar) !== false) && ($returnVar === 0))) {
        throw new \ErrorException(__FUNCTION__."($command)");
    };
}
function p($result, $pad_length)
{
    return is_string($result)
        ? str_pad($result, $pad_length)
        : (($result) ? str_pad('○', $pad_length + 1) : str_pad('×', $pad_length)); //< East Asian Width
}
if (is_dir($root)) {
    echo "Failed: $root is exists.";
    return;
}
mkdir($root, 0777, true);
$realpath = realpath($root).'\\';
mkdir($root.'d1', 0777, true);
mkdir($root.'d2', 0777, true);
touch($root.'d1\\file.txt');
touch($root.'d2\\file.txt');
mkdir($root.'d3\\dir_real', 0777, true);
touch($root.'d3\\file_real.txt');
chdir($root.'d3');
mklink("/d dir_SYMLINKD         {$realpath}d1");
mklink("   file_SYMLINK         {$realpath}d1\\file.txt");
mklink("/j dir_JUNCTION         {$realpath}d1");
mklink("/h file_HARDLINK        {$realpath}d1\\file.txt");
mklink("/d dir_SYMLINKD_broken  {$realpath}d2");
mklink("   file_SYMLINK_broken  {$realpath}d2\\file.txt");
mklink("/j dir_JUNCTION_broken  {$realpath}d2");
chdir('..');
rename('d2', 'd2_');
echo '|対象                  |file_exists|is_file|is_dir|is_link|getType<br>(SplFileInfo)|', "\n";
echo '|----------------------|:---------:|:-----:|:----:|:-----:|------------------------|', "\n";
foreach(new \FilesystemIterator('d3') as $file) {
    $name = $file->getPathname();
    echo '|', implode('|', [
        p($file->getFilename(), 22),
        p(file_exists($name), 11),
        p(is_file($name), 7),
        p(is_dir($name), 6),
        p(is_link($name), 7),
        p(@$file->getType(), 24),
        //p(@unlink($name), 11),
        //p(@rmdir($name), 11),
    ]), "|\n";
}
var_dump(is_file('d1\\file.txt'));
```