PHP 文件系统操作:大文件处理 / 目录遍历 / 权限管理

wbcm
wbcm 见习用户见习用户
发布于 2026-10-02 20:15 ·11 浏览 ·2 回复

学完这篇,你能用 PHP 安全地读写几百 MB 的大文件、把任意目录树遍历干净、并把权限配到「能写但不能被当成脚本执行」——三者都是上传目录、附件目录最容易出事的地方。

第一步:先确认三个环境前提

在写任何文件代码前,先搞清楚这三件事,否则后面全是玄学问题:

  1. 内存上限:`memory_limit`,`php -i | grep memory_limit` 或 `phpinfo()` 里看。一次性读大文件超限就是在这里爆的。
  2. 执行时限:`max_execution_time`,Web 请求里默认常见 30 秒。处理大文件时脚本开头写 `set_time_limit(0);` 解除。
  3. 运行用户:PHP-FPM 通常以 `www` / `www-data` 跑。谁运行,谁就是新建文件的所有者。用 `ps aux | grep php-fpm` 确认。

注意:`set_time_limit(0)` 只对当前脚本生效。真正耗时几分钟以上的任务(批量转码、全站索引)应该交给计划任务或 CLI,别挂在 Web 请求里。

第二步:读大文件——永远别用 file_get_contents

`file_get_contents()` 会把整个文件塞进内存,读 500MB 文件基本等于自杀。三种替代方案按场景选:

逐行读(日志、文本):

$fp = fopen($path, 'rb');
if (!$fp) { exit('无法打开'); }
while (($line = fgets($fp)) !== false) {
    // 处理 $line
}
fclose($fp);

定长块读(二进制、任意大小):

$fp = fopen($path, 'rb');
while (!feof($fp)) {
    $chunk = fread($fp, 1024 * 64); // 64KB 一块
    // 处理 $chunk
}
fclose($fp);

下载大文件(避免撑爆内存):

while (ob_get_level()) { ob_end_clean(); } // 关掉所有输出缓冲
header('Content-Type: application/octet-stream');
header('Content-Length: ' . filesize($path));
readfile($path);   // 或 fpassthru($fp)

写大文件同理:用 `fopen($path, 'ab')` + 循环 `fwrite()` + `fflush()`,不要用字符串一路 `file_put_contents($path, $str, FILE_APPEND)` 拼几十万次。

第三步:目录遍历——三种写法别用混

只看一层、要全部条目 → `scandir()`,记住必须过滤 `.` 和 `..`:

foreach (scandir($dir) as $name) {
    if ($name === '.' || $name === '..') continue;
    $full = $dir . DIRECTORY_SEPARATOR . $name;
    if (is_file($full)) { /* ... */ }
}

按扩展名筛选一层 → `glob($dir . '/*.jpg')`。

递归整棵树 → `RecursiveDirectoryIterator` + `RecursiveIteratorIterator`:

$it = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
    RecursiveIteratorIterator::SELF_FIRST
);
foreach ($it as $file) {
    if ($file->isFile()) {
        echo $file->getPathname(), ' ', $file->getSize(), " bytes\n";
    }
}

要跳过某些目录(比如缓存、`.git`),用 `RecursiveCallbackFilterIterator` 在回调里 `return false`。

注意:遍历路径如果来自用户输入,先用 `realpath()` 解析并确认它在你允许的根目录之内,否则 `../../` 就能把整台服务器读一遍。永远不要直接拼接用户传来的路径。

第四步:权限管理——属主、模式、可写性

  • 先判断再操作:`is_writable()` / `is_readable()` 比直接 fopen 报错友好。
  • 模式选择:普通文件 `0644`,目录 `0755`;需要同组写入的上传目录用 `0775`。不要用 `0777`。
  • 目录的执行位:目录的 `x` 表示「可进入」,少了它连 `cd` 都进不去,遍历直接失败。
  • 改属主:`chown($path, 'www')` 需要 root 权限,PHP 里通常做不到,得在面板或命令行处理。

注意:用 root 解压或上传的文件,属主是 root,PHP-FPM 的 www 用户写不进去。这是「上传提示目录不可写」最常见的原因——改成 `chown -R www:www uploads` 即可。同时上传目录应关闭脚本执行(Nginx 里对该目录 `location` 禁止 `.php`),防止有人传个伪装图片当后门跑。

第五步:两个能直接抄的组合函数

// 递归算目录总大小
function dir_size(string $dir): int {
    $total = 0;
    $it = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
    );
    foreach ($it as $f) {
        if ($f->isFile()) $total += $f->getSize();
    }
    return $total;
}

// 安全删除目录(先确认在允许范围内)
function rm_dir(string $dir, string $allowRoot): bool {
    $real = realpath($dir);
    if ($real === false || strpos($real, realpath($allowRoot)) !== 0) return false;
    $it = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($real, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST
    );
    foreach ($it as $f) {
        $f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname());
    }
    return rmdir($real);
}

`CHILD_FIRST` 确保先删子项再删父目录,否则 `rmdir` 一定报「目录非空」。

小结

  • 大文件一律 `fopen` + 分块/逐行,禁用 `file_get_contents`;输出用 `readfile()` 并先清空输出缓冲。
  • 一层用 `scandir`/`glob`,递归用 `RecursiveIteratorIterator`,记得 `SKIP_DOTS`。
  • 权限三要素:属主是运行用户、文件 `0644` 目录 `0755`、目录必须有执行位。
  • 所有用户提供的路径先 `realpath()` 再校验前缀,防目录穿越。
  • 上传目录「可写但不可执行」,这是安全底线。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-681.html
转载请注明出处,版权归原作者所有。

全部回复 2

yipeng
yipeng 正式会员正式会员认证极客认证极客 1楼 2026-10-02 20:23

接着帖子里第三步往下补:目录遍历优先用 SPL 迭代器,权限管理的核心不是 chmod 而是「禁执行」。

目录遍历的三种正确姿势,别混用:

  • 单层、要完整条目 → `scandir()`,务必跳过 `.` 和 `..`;
  • 递归整棵树 → `RecursiveDirectoryIterator` + `RecursiveIteratorIterator`,比手写递归稳,能顺手用 `SKIP_DOTS` 和 `FilesystemIterator::UNIX_PATHS`;
  • 只需要匹配特定后缀(比如清临时文件)→ `glob()` 或 `glob('**/*', GLOB_BRACE)` 足够。

手写递归最大的坑是符号链接死循环——目录里有个链回上层的 symlink,递归直接跑飞。用 `is_link()` 判断,或者 SPL 那边加 `RecursiveIteratorIterator::CHILD_FIRST` 配合自己过滤。另外任何路径都先过 `realpath()` 校验,防止 `../../etc` 这类穿越。

权限管理部分,记住一句:上传目录要「能写」但绝不能「能被当脚本跑」。目录 755、文件 644,所有者是 PHP-FPM 运行用户(`www`/`www-data`),永远不要 777。更关键的是在 Web 服务器层直接断掉执行:

  • Nginx:`location ~ ^/uploads/.\.(php|phtml|phar)$ { deny all; }`,或者干脆给 uploads 单独一个 location 只做静态文件;
  • Apache:在该目录放 `.htaccess` 写 `php_flag engine off`(或 `RemoveHandler .php`)。

光靠上传白名单挡不住 .htaccess 被覆盖、或解析漏洞这类情况,所以「白名单 + 服务器层禁执行 + 目录不可解析」是三件套,缺一不可。

常见坑:`file_get_contents` 读大文件爆 `memory_limit` 时,报错信息常常是「Allowed memory size exhausted」,但真正元凶经常是 `readfile` 前的 `ob_start` 缓冲没清;下载大文件前那句 `while (ob_get_level()) ob_end_clean();` 别忘了。

zero
zero 见习用户见习用户 #333 2楼 2026-10-02 20:32
yipeng:接着帖子里第三步往下补:**目录遍历优先用 SPL 迭代器,权限管理的核心不是 chmod 而是「禁执行」**。 **目录遍历的三种正确姿势**,别混用: -…

补充得很到位,尤其「禁执行比 chmod 更关键」这一点,很多人只盯着 777 却忘了服务器解析这一层。我顺着再加三个实际踩过的细节。

realpath 校验要防前缀绕过。 `realpath()` 对不存在的文件返回 false,所以必须在 fopen 之前校验,别在校验完再拼路径。而且不能简单 `strpos($real, $base) === 0`——`/var/www/uploads-evil` 会误判通过。正确做法是 base 先 `realpath()` 再用 `DIRECTORY_SEPARATOR` 结尾做前缀比较。

删目录树时 symlink 要先当文件处理。 `RecursiveDirectoryIterator` 默认不跟随链接(没加 `FOLLOW_SYMLINKS`),但 `isDir()` 对指向目录的 symlink 会返回 true,于是 `rmdir` 报错或删错。配合 `CHILD_FIRST` 时加一层:是链接就 `unlink`,别再往下走。

chmod 别写十进制。 `chmod($f, 0644)` 对,`chmod($f, 644)` 等于八进制 1204,权限直接乱掉;另外 PHP 进程的 umask 会削掉新建文件的位,需要固定权限就 `chmod` 显式设置,别指望 `fopen` 时给的 mode。

放回本社区的场景:Clara 的上传白名单和图片二次校验是 PHP 层兜底,服务器层禁执行就是第二道锁,两边都在才稳。真遇到上传失败,先看后台「系统设置→上传」里"允许的扩展名"和"附件允许的扩展名"这两个独立字段,再看 PHP 的 `upload_max_filesize`,最后确认 uploads 可写——页面会直接提示是哪一类。