Skip to content

特殊包装器

概述

PHP 除了常见的 file://http:// 等包装器外,还提供了几种特殊用途的包装器:data://glob://phar://zip://。它们适用于数据 URI 处理、文件模式匹配、PHP 归档访问和 ZIP 文件操作等场景。

包装器一览

包装器用途示例
data://内嵌数据(RFC 2397)data://text/plain;base64,SGVsbG8=
glob://文件模式匹配glob:///tmp/*.txt
phar://PHP 归档访问phar://app.phar/config.ini
zip://ZIP 文件访问zip://archive.zip#file.txt

基础概念

包装器启用检查

php
<?php
// 检查已注册的流包装器
$wrappers = stream_get_wrappers();
echo "已注册包装器: " . implode(', ', $wrappers) . "\n";

// 常见输出: php, file, http, ftp, compress.zlib, compress.bzip2, data, glob, phar, zip

// 检查特定包装器是否可用
if (in_array('phar', stream_get_wrappers())) {
    echo "phar 包装器可用\n";
}

语法与代码

data:// — 内嵌数据

data:// 包装器遵循 RFC 2397 标准,允许在 URI 中直接嵌入数据。

php
<?php
declare(strict_types=1);

// data://text/plain — 纯文本
$text = file_get_contents('data://text/plain,Hello%20World');
echo $text . "\n"; // "Hello World"

// data://text/plain;base64 — Base64 编码
$encoded = base64_encode('你好,世界!');
$data = file_get_contents("data://text/plain;base64,{$encoded}");
echo $data . "\n"; // "你好,世界!"

// data://text/html — HTML 内容
$html = file_get_contents('data://text/html,<h1>Hello</h1>');
echo $html . "\n"; // "<h1>Hello</h1>"

// data://application/json — JSON 数据
$json = file_get_contents('data://application/json,{"name":"test"}');
$obj = json_decode($json, true);
echo $obj['name'] . "\n"; // "test"

// data://image/png;base64 — 图片数据(从 Base64 创建图片)
$base64Image = file_get_contents('/path/to/image.base64');
$imageData = file_get_contents("data://image/png;base64,{$base64Image}");
file_put_contents('/path/to/image.png', $imageData);

// data:// 参数 — 指定编码和字符集
// data://text/plain;charset=utf-8;base64,5L2g5aW9
$data = file_get_contents('data://text/plain;charset=utf-8;base64,5L2g5aW9');
echo $data . "\n"; // "你好"

Data URI 生成器

php
<?php
declare(strict_types=1);

class DataUriGenerator
{
    /**
     * 从文件生成 Data URI
     */
    public static function fromFile(string $filePath, ?string $mimeType = null): string
    {
        if (!file_exists($filePath)) {
            throw new RuntimeException("文件不存在: {$filePath}");
        }

        if ($mimeType === null) {
            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            $mimeType = finfo_file($finfo, $filePath);
            finfo_close($finfo);
        }

        $data = file_get_contents($filePath);
        $base64 = base64_encode($data);

        return "data:{$mimeType};base64,{$base64}";
    }

    /**
     * 从字符串生成 Data URI
     */
    public static function fromString(string $content, string $mimeType = 'text/plain'): string
    {
        $base64 = base64_encode($content);
        return "data:{$mimeType};base64,{$base64}";
    }

    /**
     * 生成 HTML 中的 img Data URI
     */
    public static function imageTag(string $imagePath, string $alt = ''): string
    {
        $dataUri = self::fromFile($imagePath);
        return sprintf('<img src="%s" alt="%s">', $dataUri, htmlspecialchars($alt));
    }
}

// 使用示例
$csvUri = DataUriGenerator::fromFile('/data/report.csv', 'text/csv');
$csvContent = file_get_contents($csvUri);

$imgTag = DataUriGenerator::imageTag('/images/logo.png', 'Logo');
echo $imgTag . "\n";
// <img src="data:image/png;base64,iVBOR..." alt="Logo">

Data URI 限制

Data URI 没有 MIME 类型验证,不适合用于不受信任的数据。浏览器对 Data URI 大小有限制(通常约 2MB)。不要将用户上传的文件转换为 Data URI。

glob:// — 文件模式匹配

php
<?php
declare(strict_types=1);

// glob:// 包装器 — 通过流方式使用 glob 模式匹配
$pattern = 'glob://' . __DIR__ . '/*.php';

// 读取所有匹配文件的内容(连接为一个字符串)
$content = file_get_contents($pattern);
// 注意: 这会将所有匹配文件的内容拼接在一起

// 逐行读取所有匹配文件
$handle = fopen($pattern, 'r');
if ($handle) {
    while (($line = fgets($handle)) !== false) {
        echo $line;
    }
    fclose($handle);
}

// glob:// vs glob() 函数对比
// glob() — 返回文件名数组
$files = glob(__DIR__ . '/*.php');
foreach ($files as $file) {
    echo $file . "\n";
}

// glob:// — 可以像文件一样通过流操作读取所有文件内容
$allContent = file_get_contents('glob://' . __DIR__ . '/*.md');

// 递归匹配
$allPhpFiles = file_get_contents('glob://' . __DIR__ . '/**/*.php');

phar:// — PHP 归档访问

php
<?php
declare(strict_types=1);

// phar:// 允许访问 .phar 归档文件中的内容
// .phar 是 PHP 的归档格式,类似 JAR

// 读取 phar 中的文件
$config = file_get_contents('phar://app.phar/config/settings.ini');
$className = file_get_contents('phar://app.phar/src/Controller.php');

// 遍历 phar 中的文件
$it = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator('phar://app.phar/src')
);

foreach ($it as $file) {
    echo $file->getPathname() . "\n";
}

// 使用 phar:// 包含文件
require 'phar://app.phar/vendor/autoload.php';

// 判断 phar 中文件是否存在
if (file_exists('phar://app.phar/README.md')) {
    echo "README 存在\n";
}

创建 Phar 归档

php
<?php
declare(strict_types=1);

// 创建 Phar 归档(需要 phar.readonly = 0)
$pharPath = __DIR__ . '/app.phar';

if (file_exists($pharPath)) {
    Phar::unlinkArchive($pharPath);
}

$phar = new Phar($pharPath);
$phar->setStub($phar->createDefaultStub('bin/console.php'));
$phar->setMetadata([
    'version' => '1.0.0',
    'author' => 'Developer',
]);

// 添加文件到归档
$phar->buildFromDirectory(__DIR__ . '/src', '/src');
$phar->addFile('config/settings.ini');

// 压缩整个归档
$phar->compressFiles(Phar::GZ);

echo "Phar 创建成功: {$pharPath}\n";
echo "Phar 大小: " . filesize($pharPath) . " 字节\n";

// 设置为只读(生产环境)
// ini_set('phar.readonly', '1');

Phar 安全

Phar 文件可以包含 PHP 代码。在 phar.readonly = 1(默认)时,不能创建或修改 Phar 文件。生产环境建议保持只读。

zip:// — ZIP 文件访问

php
<?php
declare(strict_types=1);

// zip:// 语法: zip://archive.zip#path/to/file
// 注意使用 # 而非 / 分隔

// 读取 ZIP 中的文件
$content = file_get_contents('zip:///path/to/archive.zip#config.json');
$config = json_decode($content, true);

// 读取 ZIP 中的文本文件
$readme = file_get_contents('zip:///path/to/archive.zip#README.md');
echo $readme . "\n";

// 使用相对路径
$readme = file_get_contents('zip://archive.zip#README.md');

// 列出 ZIP 文件内容
$zip = new ZipArchive();
if ($zip->open('archive.zip') === true) {
    for ($i = 0; $i < $zip->numFiles; $i++) {
        echo $zip->getNameIndex($i) . "\n";
    }
    $zip->close();
}

ZIP 操作工具类

php
<?php
declare(strict_types=1);

class ZipHelper
{
    /**
     * 从 ZIP 中提取文件
     */
    public static function extractFile(string $zipPath, string $entryPath, string $outputPath): void
    {
        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) {
            throw new RuntimeException("无法打开 ZIP: {$zipPath}");
        }

        $content = $zip->getFromName($entryPath);
        if ($content === false) {
            $zip->close();
            throw new RuntimeException("ZIP 中不存在: {$entryPath}");
        }

        file_put_contents($outputPath, $content);
        $zip->close();
    }

    /**
     * 将文件添加到 ZIP
     */
    public static function addToZip(string $zipPath, array $files): void
    {
        $zip = new ZipArchive();

        $mode = file_exists($zipPath) ? ZipArchive::OVERWRITE : ZipArchive::CREATE;

        if ($zip->open($zipPath, $mode) !== true) {
            throw new RuntimeException("无法创建 ZIP: {$zipPath}");
        }

        foreach ($files as $localName => $filePath) {
            if (is_int($localName)) {
                $localName = basename($filePath);
            }
            $zip->addFile($filePath, $localName);
        }

        $zip->close();
    }

    /**
     * 创建 ZIP 备份
     */
    public static function backup(string $directory, string $outputZip): void
    {
        $zip = new ZipArchive();
        if ($zip->open($outputZip, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
            throw new RuntimeException("无法创建 ZIP: {$outputZip}");
        }

        $files = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
            RecursiveIteratorIterator::SELF_FIRST
        );

        foreach ($files as $file) {
            $filePath = $file->getRealPath();
            $relativePath = substr($filePath, strlen($directory) + 1);

            if (is_file($filePath)) {
                $zip->addFile($filePath, $relativePath);
            }
        }

        $zip->close();
        echo "备份完成: {$outputZip}\n";
    }
}

// 使用示例
ZipHelper::backup('/data/app/uploads', '/backup/uploads_' . date('Ymd') . '.zip');
ZipHelper::addToZip('/data/reports.zip', [
    'report.csv' => '/tmp/daily_report.csv',
    'summary.pdf' => '/tmp/summary.pdf',
]);

注意事项

安全考虑

php
<?php
// 1. data:// 注入风险
// 不要将不受信任的数据作为 data:// URI 使用
// 可能导致代码注入(如果接收端解析为 PHP)
$dangerous = $_GET['data'] ?? '';
// file_get_contents("data://text/plain,{$dangerous}") // 危险!

// 2. phar:// 反序列化漏洞
// 某些函数(如 file_exists, is_file)会触发 phar 反序列化
// 危险函数: file_exists, is_file, is_dir, is_link, stat, fopen
// 生产环境设置 phar.readonly = 1 禁用创建/修改 Phar

// 3. zip:// 路径遍历
// 确保从 ZIP 提取的文件路径不包含 .. 遍历
$entry = $zip->getNameIndex($i);
if (str_contains($entry, '..')) {
    continue; // 跳过危险路径
}

Phar 反序列化攻击

Phar 文件包含序列化的元数据。当通过 phar:// 或某些文件函数访问 Phar 时,会触发反序列化。攻击者可以上传恶意 Phar 文件并利用此漏洞执行代码。防御方法: 设置 phar.readonly = 1 和禁用 unserialize

最佳实践

1. 使用场景选择

php
<?php
// data:// — 适合: 小图标、内联 CSS、API 响应中的小文件
// 不适合: 大文件、用户上传的文件

// glob:// — 适合: 批量读取同类型文件、日志合并
// 不适合: 需要精确控制每个文件的场景

// phar:// — 适合: PHP 应用分发、CLI 工具打包
// 不适合: 存储用户数据、频繁修改的内容

// zip:// — 适合: 归档提取、批量导入
// 不适合: 频繁随机访问 ZIP 内文件

2. 性能建议

bash
# 启用 phar.readonly
# php.ini
phar.readonly = 1

# 检查可用的包装器
php -r "print_r(stream_get_wrappers());"

参考链接