PHP 语法进阶 16|文件与目录:读写、路径与那些致命的路径穿越

读文件:三种场景三种写法

小文件,一次读完

<?php
$content = file_get_contents("/etc/app/config.json");
if ($content === false) {
    throw new RuntimeException("读取失败");
}

注意 file_get_contents 失败时返回 false,成功但内容为空时返回 ""。所以判断要用 === false,不能写 if (!$content)(空文件会被误判成失败)。

大文件,逐行读

<?php
$fp = fopen("big.log", "r");
if ($fp === false) throw new RuntimeException("打不开");

try {
    while (($line = fgets($fp)) !== false) {
        process($line);        // 内存里始终只有一行
    }
} finally {
    fclose($fp);
}

fgets 每次读一行(到换行为止)。如果某一行特别长(比如没有换行的巨型 JSON),它会按 length 参数切,默认读到行尾。

不想手写 fopen/fclose 就用 SplFileObject:

<?php
$file = new SplFileObject("big.log", "r");
$file->setFlags(SplFileObject::DROP_NEW_LINE | SplFileObject::SKIP_EMPTY);
foreach ($file as $line) {
    process($line);
}

foreach 遍历 SplFileObject 就是逐行读,代码干净且不用管资源释放。

二进制,按块读

<?php
$fp = fopen("video.bin", "rb");      // b 表示二进制模式
while (!feof($fp)) {
    $chunk = fread($fp, 8192);
    if ($chunk === false) break;
    echo $chunk;
}
fclose($fp);

在 Windows 上一定要加 b。不加 b 时,\r\n 会被转成 \n(文本模式),处理图片、压缩包会损坏文件。Linux 上无所谓,但为了可移植性,一律写 rb。

写文件:先想清楚"要不要原子"

<?php
// 覆盖写
file_put_contents("out.txt", $data);

// 追加
file_put_contents("access.log", $line . PHP_EOL, FILE_APPEND | LOCK_EX);

FILE_APPEND 让内容追加而不是覆盖,LOCK_EX 加排他锁。并发写日志时这两个都要加,否则多进程同时写会把行交错甚至覆盖。

原子写:避免"读到半个文件"

<?php
function atomicWrite(string $path, string $data): void
{
    $dir = dirname($path);
    $tmp = $dir . "/." . basename($path) . "." . bin2hex(random_bytes(4)) . ".tmp";

    if (file_put_contents($tmp, $data, LOCK_EX) === false) {
        throw new RuntimeException("写临时文件失败");
    }
    if (!rename($tmp, $path)) {         // rename 在同一文件系统上是原子的
        @unlink($tmp);
        throw new RuntimeException("替换失败");
    }
}

直接 file_put_contents 覆盖时,如果进程在中途崩溃,文件会只剩一半内容。先写临时文件再 rename 是配置、缓存、导出文件的标配做法——读取方永远看到"完整的旧版本"或"完整的新版本"。

rename 的原子性有一个前提:临时文件和目标必须在同一个文件系统。跨挂载点会退化成"复制 + 删除"。

fopen 模式表

模式 读 写 文件不存在时 指针位置
r ✓ 失败 开头
r+ ✓ ✓ 失败 开头
w ✓ 创建 开头,并清空
w+ ✓ ✓ 创建 开头,并清空
a ✓ 创建 末尾
a+ ✓ ✓ 创建 末尾
x ✓ 失败(已存在) 开头
x+ ✓ ✓ 失败(已存在) 开头
c ✓ 创建 开头,不清空

c 是 w 的安全版本:不截断已有内容,适合"打开后按需修改某一段"。

路径处理:必须掌握的五个函数

<?php
$path = "/var/www/uploads/2026/report.final.pdf";

echo basename($path);         // report.final.pdf
echo dirname($path);          // /var/www/uploads/2026
echo basename($path, ".pdf"); // report.final    去掉指定后缀

$info = pathinfo($path);
print_r($info);
// ['dirname' => '/var/www/uploads/2026',
//  'basename' => 'report.final.pdf',
//  'extension' => 'pdf',
//  'filename' => 'report.final']

echo realpath("./../www");    // 解析成绝对路径,不存在的路径返回 false

realpath() 会展开 ..、解析软链接、转成绝对路径,是防路径穿越的关键工具。

另外用 __DIR__ 拼路径,永远不要用相对路径:

<?php
require __DIR__ . "/../config.php";

路径穿越:一个真实可打的漏洞

<?php
// ✗ 用户传 ?file=../../../../etc/passwd
$file = $_GET['file'];
readfile("/var/www/files/" . $file);

../ 会被原样拼进去,攻击者能读服务器上任何可读文件。三种防护:

<?php
// 1) 白名单(最稳,能覆盖 90% 的场景)
$allowed = ['invoice', 'contract', 'report'];
if (!in_array($file, $allowed, true)) {
    throw new RuntimeException("非法文件");
}

// 2) 只取文件名,丢掉任何目录成分
$safe = basename($file);

// 3) 规范化后判断是否仍在允许目录内
$base = realpath("/var/www/files");
$full = realpath($base . "/" . $file);

if ($full === false || !str_starts_with($full, $base . DIRECTORY_SEPARATOR)) {
    throw new RuntimeException("非法路径");
}

第 3 种是通用做法,但有细节:str_starts_with 判断前缀时,必须比到带分隔符的 $base . "/",否则 /var/www/files-evil 也会被误判为合法(因为它的前缀同样是 /var/www/files)。

realpath() 返回 false 表示路径不存在,这本身也是一个信号(可以当作"攻击探测"记日志)。

目录操作

<?php
// 创建(递归)
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
    throw new RuntimeException("建目录失败");
}

这段 mkdir 的写法有点绕,原因是并发下两个进程同时 mkdir 会有一个失败,但目录其实已经建好了。先 is_dir 检查、mkdir 之后再检查一次,是为了处理这种竞态。

<?php
// 列目录
$items = scandir("/var/log");                       // 含 . 和 ..
$logs  = glob("/var/log/*.log");                     // 支持通配符
$files = new FilesystemIterator("/var/log", FilesystemIterator::SKIP_DOTS);

foreach ($files as $f) {
    echo $f->getFilename(), ' ', $f->getSize(), PHP_EOL;
}

// 递归列目录
$it = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator("/var/log", FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $f) {
    if ($f->isFile()) echo $f->getPathname(), PHP_EOL;
}

删除目录要自己递归:

<?php
function rrmdir(string $dir): void
{
    if (!is_dir($dir)) return;
    $it = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::CHILD_FIRST       // 先删子项,再删目录
    );
    foreach ($it as $f) {
        $f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname());
    }
    rmdir($dir);
}

CHILD_FIRST 是关键:不先删子项,目录本身删不掉。

文件锁 flock

<?php
$fp = fopen("counter.txt", "c+");
flock($fp, LOCK_EX);

$n = (int)fread($fp, 20);
$n++;
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, (string)$n);
fflush($fp);
flock($fp, LOCK_UN);
fclose($fp);

flock 是建议性锁(advisory),只对同样用 flock 的程序生效。而且在 NFS 上不可靠。做计数器、日志序列号可以用,做业务数据的并发控制请用数据库。

文件权限

<?php
chmod("/var/www/uploads", 0755);
chmod("/var/www/config.php", 0644);

常规约定:

  • 目录 0755:所有者可读写执行,组和其他人可读执行(需要能"进入"才能访问里面的文件)。
  • 普通文件 0644:所有者可读写,其他人只读。
  • 可执行文件 0755。
  • 配置/密钥文件 0600:只有所有者能读。

权限数字用八进制字面量 0755,不要写 755。chmod($f, 755) 传的是十进制 755,等于八进制 1363,结果是诡异的权限位。

下载文件

<?php
$path = "/var/www/exports/report.pdf";

if (!is_file($path) || !is_readable($path)) {
    http_response_code(404);
    exit;
}

header("Content-Type: application/pdf");
header('Content-Disposition: attachment; filename="report.pdf"');
header("Content-Length: " . filesize($path));
header("X-Content-Type-Options: nosniff");

readfile($path);

三个要点:

  • Content-Length 让浏览器能显示进度条。
  • X-Content-Type-Options: nosniff 防止浏览器猜类型(否则一个伪装成图片的 HTML 可能被当网页执行)。
  • 文件名如果来自用户输入,必须过滤引号和换行,否则能注入额外的响应头(Content-Disposition 头注入)。

大文件不要用 readfile 一次性读,配合 ob_end_flush() + flush() 分段输出,或者直接交给 Nginx 的 X-Accel-Redirect(性能最好)。

临时文件

<?php
$tmp = tmpfile();                     // 自动删除的临时文件句柄
$dir = sys_get_temp_dir();            // 系统临时目录
$name = tempnam($dir, "php_");        // 生成唯一文件名

tempnam() 返回的是安全随机名(它内部用 mkstemp 风格),比 uniqid() 可靠得多。不要用 uniqid() / rand() 拼临时文件名,并发下会撞名,而且可预测。

坑清单

  1. file_get_contents 返回 false 和 "" 混用判断,空文件被当失败。
  2. Windows 下不加 b 处理二进制,文件损坏。
  3. 直接覆盖写配置文件,崩溃时留下半个文件。
  4. rename 跨文件系统不原子。
  5. 路径拼接直接用用户输入,路径穿越。
  6. str_starts_with 判断目录前缀不带分隔符,被同前缀目录绕过。
  7. chmod($f, 755) 写十进制,权限全乱。
  8. 多进程写同一日志不加 LOCK_EX,行交错。
  9. NFS 上依赖 flock 做互斥。
  10. uniqid() 当唯一文件名,并发撞名。
  11. rmdir 删非空目录失败,不先删子项。