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() 拼临时文件名,并发下会撞名,而且可预测。
坑清单
file_get_contents返回false和""混用判断,空文件被当失败。- Windows 下不加
b处理二进制,文件损坏。 - 直接覆盖写配置文件,崩溃时留下半个文件。
rename跨文件系统不原子。- 路径拼接直接用用户输入,路径穿越。
str_starts_with判断目录前缀不带分隔符,被同前缀目录绕过。chmod($f, 755)写十进制,权限全乱。- 多进程写同一日志不加
LOCK_EX,行交错。 NFS上依赖flock做互斥。uniqid()当唯一文件名,并发撞名。rmdir删非空目录失败,不先删子项。