PHP 內建 rmdir() 要求目錄為空,否則回傳 false 並拋出警告。刪除包含檔案/子目錄的目錄,必須遞歸遍歷:先刪除所有檔案(unlink),再遞歸刪除子目錄,最後刪除當前目錄。
/** * 遞歸刪除目錄及其內容 * * @param string $dirPath 目標目錄絕對路徑 * @param bool $deleteSelf 是否刪除目標目錄本身(false 僅清空內容) * @param array $options 進階選項 * - 'skip_errors' => bool, 忽略單個檔案刪除失敗繼續執行 (預設 false) * - 'follow_links' => bool, 是否跟隨符號連結 (預設 false,避免刪除連結目標) * - 'max_depth' => int, 最大遞歸深度防無限迴圈 (預設 100) * @return bool 完全成功 true,失敗 false */ function rmdirRecursive( string $dirPath, bool $deleteSelf = true, array $options = [] ): bool { $opts = array_merge([ 'skip_errors' => false, 'follow_links' => false, 'max_depth' => 100, ], $options); // 正規化路徑 $dirPath = rtrim(realpath($dirPath), DIRECTORY_SEPARATOR); if ($dirPath === false || !is_dir($dirPath)) { trigger_error("目錄不存在: $dirPath", E_USER_WARNING); return false; } // 遞歸內部函數 $success = true; $depth = 0; $fn = function ($currentDir, $currentDepth) use (&$fn, $opts, &$success) { if ($currentDepth > $opts['max_depth']) { trigger_error("超過最大遞歸深度: $currentDir", E_USER_WARNING); $success = false; return; } $handle = @opendir($currentDir); if (!$handle) { if (!$opts['skip_errors']) $success = false; return; } while (($entry = readdir($handle)) !== false) { if ($entry === '.' || $entry === '..') continue; $fullPath = $currentDir . DIRECTORY_SEPARATOR . $entry; // 符號連結處理 if (is_link($fullPath)) { if (!$opts['follow_links']) { // 僅刪除連結本身 if (!@unlink($fullPath) && !$opts['skip_errors']) $success = false; } else { // 跟隨連結刪除目標(危險,預設關閉) $fn($fullPath, $currentDepth + 1); if (!@rmdir($fullPath) && !$opts['skip_errors']) $success = false; } continue; } if (is_dir($fullPath)) { // 遞歸處理子目錄 $fn($fullPath, $currentDepth + 1); if (!@rmdir($fullPath) && !$opts['skip_errors']) $success = false; } elseif (is_file($fullPath)) { // 刪除檔案 if (!@unlink($fullPath) && !$opts['skip_errors']) $success = false; } } closedir($handle); }; $fn($dirPath, 0); // 最後刪除目標目錄本身 if ($deleteSelf && !@rmdir($dirPath) && !$opts['skip_errors']) { $success = false; } return $success; } // 使用範例 // 1. 完整刪除 rmdirRecursive('/var/www/uploads/temp_123'); // 2. 僅清空目錄內容,保留目錄 rmdirRecursive('/var/www/cache', false); // 3. 生產環境建議:忽略單一失敗、不跟隨連結 rmdirRecursive('/tmp/app_cache', true, ['skip_errors' => true]);
function rmdirSelective( string $dirPath, array $options = [] ): bool { $opts = array_merge([ 'extensions' => null, // 允許刪除的副檔名陣列 ['log','tmp'],null 為全部 'older_than' => null, // 秒數,僅刪除修改時間早於此秒數的檔案 'newer_than' => null, // 秒數,僅刪除修改時間晚於此秒數的檔案 'min_size' => null, // 位元組,最小檔案大小 'max_size' => null, // 位元組,最大檔案大小 'delete_empty_dirs' => true, // 刪除檔案後是否清理空目錄 'dry_run' => false, // 試運行,不實際刪除 ], $options); $deleted = 0; $errors = 0; $fn = function ($currentDir) use (&$fn, $opts, &$deleted, &$errors) { $handle = @opendir($currentDir); if (!$handle) return; while (($entry = readdir($handle)) !== false) { if ($entry === '.' || $entry === '..') continue; $fullPath = $currentDir . DIRECTORY_SEPARATOR . $entry; if (is_dir($fullPath)) { $fn($fullPath); if ($opts['delete_empty_dirs'] && !$opts['dry_run']) { @rmdir($fullPath); // 僅刪除空目錄 } } elseif (is_file($fullPath)) { $stat = @stat($fullPath); if (!$stat) { $errors++; continue; } // 條件過濾 if ($opts['extensions'] !== null) { $ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION)); if (!in_array($ext, $opts['extensions'], true)) continue; } if ($opts['older_than'] !== null && ($stat['mtime'] > (time() - $opts['older_than']))) continue; if ($opts['newer_than'] !== null && ($stat['mtime'] < (time() - $opts['newer_than']))) continue; if ($opts['min_size'] !== null && $stat['size'] < $opts['min_size']) continue; if ($opts['max_size'] !== null && $stat['size'] > $opts['max_size']) continue; if (!$opts['dry_run'] && @unlink($fullPath)) { $deleted++; } elseif (!$opts['dry_run']) { $errors++; } } } closedir($handle); }; $fn($dirPath); if ($opts['dry_run']) { echo "試運行完成:將刪除 $deleted 個檔案\n"; } else { echo "實際刪除:$deleted 個檔案,錯誤:$errors 次\n"; } return $errors === 0; } // 使用範例:清理 7 天前的 .log 檔案 rmdirSelective('/var/log/app', [ 'extensions' => ['log'], 'older_than' => 7 * 24 * 3600, 'delete_empty_dirs' => true ]);
| 陷阱 | 後果 | 解決方案 |
|---|---|---|
直接 rmdir($dir) 非空目錄 | 回傳 false、警告 | 必須先遞歸清空 |
| 未檢查符號連結 | 誤刪連結指向的真實目錄 | is_link() 判斷,預設只刪連結本身 |
| 權限不足 | unlink/rmdir 失敗 | 檢查 is_writable()、PHP 進程使用者權限、SELinux/AppArmor |
| 檔案被其他進程佔用 | Windows 下無法刪除 | 延遲刪佇列、重啟服務、使用 MoveFileEx 延遲刪除 |
| 路徑遍歷攻擊 | 刪除非預期目錄 | 使用 realpath() 正規化,限制在允許根目錄下 |
| 無限遞歸(循環連結) | 記憶體耗盡、死循環 | 設定 max_depth、檢測已訪問 inode |
unlink 刪除唯讀檔案,需先 chmod($file, 0666) 或用 attrib -R。thumbs.db, desktop.ini)。\\?\ 前綴。function safeDelete(string $path, array $options = []): bool { // 1. 路徑正規化與安全檢查 $realPath = realpath($path); if (!$realPath) return false; $allowedRoots = $options['allowed_roots'] ?? [sys_get_temp_dir(), '/var/www']; $allowed = false; foreach ($allowedRoots as $root) { if (strpos($realPath, realpath($root)) === 0) { $allowed = true; break; } } if (!$allowed) { trigger_error("嘗試刪除不允許路徑: $realPath", E_USER_ERROR); return false; } // 2. 確認是目錄 if (!is_dir($realPath)) { trigger_error("非目錄: $realPath", E_USER_WARNING); return false; } // 3. 執行刪除 return rmdirRecursive($realPath, true, $options); }