PHP
Windows
Symlink
rm
rmdir

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

はじめに

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

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

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

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

<?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
// 外部プログラムを利用する版(環境依存)。
// ディレクトリーを指すシンボリックリンクの場合、リンク先のファイル群が削除される場合がある
// (パスの末尾のセパレーターの有無とか、環境によって動作が異なる)
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 Filesystem::deleteDirectory() FilesystemIterator
フレームワーク Symfony/Filesystem Filesystem::remove() FilesystemIterator
フレームワーク Yii2 BaseFileHelper::removeDirectory() opendir
フレームワーク CodeIgniter delete_files() opendir
フレームワーク CakePHP Folder::delete() RecursiveDirectoryIterator
フレームワーク zend-cache Filesystem::rmDir() glob
フレームワーク Nette/Utility FileSystem::delete() FilesystemIterator
CMS WordPress WP_Filesystem_Direct::delete() dir
CMS Piwik Filesystem::unlinkRecursive() opendir
CMS Grav Folder::doDelete() scandir
CMS Anchor CMS delTree() scandir
CMS Drupal file_unmanaged_delete_recursive() dir
CMS EC-CUBE Symfony/Filesystem
CMS Zen Cart zen_remove_file() dir
ライブラリー League\Flysystem Local::deleteDir() RecursiveDirectoryIterator
ライブラリー Gaufrette Local::delete() RecursiveDirectoryIterator
ライブラリー util.php util::rmdir() scandir
その他 php-src(phar) Extract_Phar::_removeTmpFiles() glob
その他 Composer Filesystem::remove() RecursiveDirectoryIterator
その他 phpFastCache Directory::rrmdir() RecursiveDirectoryIterator
その他 Doctrine/Cache FileCache::doFlush() RecursiveDirectoryIterator
その他 Codeception FileSystem::deleteDir() scandir
その他 Behat FeatureContext::clearDirectory() scandir
その他 Phing FileSystem::rmdir() opendir
その他 Rocketeer League\Flysystem

Issues(ピックアップ)

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

PHP7.1.9 on Windows

対象 file_exists is_file is_dir is_link getType
(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_linkis_dir だけでは、削除方法の異なる、壊れた<SYMLINK>, <SYMLINKD>の区分けが出来ない。
  • 壊れた <JUNCTION> は、file_exists, is_file, is_dir, is_link 全て失敗する。

再度、実装してみる

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

<?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) 表作成スクリプト

<?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'));