前言

业务系统里几乎绕不开 ZIP:把多个附件打包给用户下载、把现网采集的日志归档、接收第三方上传的压缩包后再解析。System.IO.Compression 能完成最基本的压缩解压,但一旦落到真实项目里,往往会多出几个必须处理的需求:

  • 压缩包要带密码;
  • 用户上传的 ZIP 是 Windows 打的,中文文件名是 GBK 编码,解出来全是乱码;
  • 解压路径必须防住 Zip Slip(目录穿越)攻击;
  • 大文件不能一次性读进内存。

这篇文章以我在实际项目中封装的 SharpZipHelper 为例,把 ZIP 的底层约定、条目名规范化、流式写入、CRC 校验、安全解压这几件事一次讲清楚。代码基于 ICSharpCode.SharpZipLib,比原生 API 多出的能力恰好就是上面这几点。

一、先搞懂 ZIP 的三个底层约定

在写代码之前,有三个约定必须建立正确认知,后面的坑基本都源于它们。

1. 条目名(Entry Name)不是文件路径。 ZIP 规范要求条目名统一使用正斜杠 /,并且目录必须以 / 结尾,文件则不能。如果直接把 Windows 的 D:\data\a.txt 写进压缩包,解压工具在 Linux 上可能把它当成一个名字里带反斜杠的普通文件。

2. 压缩级别是 0~9。 0 表示只存储不压缩,9 表示最优压缩。业务里取中间值 5 是常见折中:压缩率够用,CPU 开销也不夸张。

3. 每个文件条目都要写 CRC32。 解压端依赖它校验数据完整性。用 ZipOutputStream 流式写入时必须自己算,因为写入前无法预知最终字节流。

搞懂这三点,再看封装就顺了。

二、条目名规范化:把本地路径翻译成 ZIP 路径

第一步是把本地文件系统路径转换成一个”干净”的条目名。这里用了两个关键 API:ZipEntry.CleanName 负责把反斜杠转成正斜杠并去掉非法字符,SplitAny('/') 负责按段切分。

public static string LocalNameToEntryName(string localName, bool isDirectory)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(localName);

    string entryName = ZipEntry.CleanName(localName);
    if (string.IsNullOrWhiteSpace(entryName))
    {
        throw new InvalidDataException($"非法本地文件系统路径:{localName}");
    }

    List<string> segments = [];
    ReadOnlySpan<char> span = entryName.AsSpan();

    foreach (var range in span.SplitAny('/'))
    {
        var segment = span[range];

        if (segment.IsEmpty || segment.Equals(".", StringComparison.Ordinal))
        {
            continue;
        }

        // 关键:拒绝任何形式的向上穿越
        if (segment.Equals("..", StringComparison.OrdinalIgnoreCase))
        {
            throw new InvalidDataException($"非法本地文件系统路径:{localName}");
        }

        segments.Add(segment.ToString());
    }

    entryName = string.Join('/', segments);
    if (string.IsNullOrWhiteSpace(entryName))
    {
        throw new InvalidDataException($"非法本地文件系统路径:{localName}");
    }

    // 目录补斜杠,文件不加
    return isDirectory ? entryName + '/' : entryName;
}

这段代码做了三件容易被忽略的事:

  • 丢弃空段和 .:a//b、a/./b 归一化成 a/b,避免压缩包里出现同名同路径的重复条目。
  • 遇到 .. 直接抛异常:不是简单跳过。因为条目名最终会写进压缩包,如果允许 ..,解压方只要不做校验就可能把文件写到目标目录之外。
  • 目录补 /:这就是前面说的规范要求,很多崩溃都是因为目录条目少了这个斜杠。

三、收集条目:递归目录、相对路径与父目录去重

对外暴露的 Zip 方法接收一批文件或目录路径,需要把它们展开成一维的 ZipEntryInfo 列表。ZipEntryInfo 里同时保留了 FullPath(读文件用)和 EntryName(写压缩包用)两个字段,职责分离得很干净。

public static void Zip(IReadOnlyList<string> inputPaths, string zipFilePath,
    int zipLevel = 5, string password = null,
    bool removeRootPath = true, bool includeBaseDirectory = true)
{
    string targetDir = Path.GetDirectoryName(zipFilePath);
    if (!string.IsNullOrEmpty(targetDir))
    {
        Directory.CreateDirectory(targetDir);
    }

    // 父目录与子目录同时存在时,只保留父目录
    var distinctPaths = IOHelper.RemoveRedundantPaths(inputPaths, removeRootPath);

    var zipEntryInfoList = new List<ZipEntryInfo>();

    foreach (string path in distinctPaths)
    {
        if (File.Exists(path))
        {
            var fi = new FileInfo(path);
            zipEntryInfoList.Add(new ZipEntryInfo(fi.FullName, fi.Name, fi.LastWriteTime, false));
        }
        else if (Directory.Exists(path))
        {
            var di = new DirectoryInfo(path);

            if (includeBaseDirectory)
            {
                zipEntryInfoList.Add(new ZipEntryInfo(di.FullName, di.Name, di.LastWriteTime, true));
            }

            // 不含自身时,以自身为基准算相对路径
            string basePath = includeBaseDirectory ? (di.Parent?.FullName ?? di.FullName) : di.FullName;

            foreach (var sdi in di.EnumerateDirectories("*", SearchOption.AllDirectories))
            {
                zipEntryInfoList.Add(new ZipEntryInfo(sdi.FullName,
                    Path.GetRelativePath(basePath, sdi.FullName), sdi.LastWriteTime, true));
            }

            foreach (var sfi in di.EnumerateFiles("*", SearchOption.AllDirectories))
            {
                zipEntryInfoList.Add(new ZipEntryInfo(sfi.FullName,
                    Path.GetRelativePath(basePath, sfi.FullName), sfi.LastWriteTime, false));
            }
        }
        else
        {
            throw new InvalidOperationException($"文件或者目录不存在:{path}");
        }
    }

    ZipInternal(zipEntryInfoList, zipFilePath, zipLevel, password, false);
}

两个细节值得展开:

includeBaseDirectory 决定压缩包里有没有最外层文件夹。 打包 D:\logs 这个目录时,如果为 true,包里就是 logs/xxx.log;为 false 则直接是 xxx.log。差别来自 basePath 取的是 di.Parent.FullName 还是 di.FullName。很多”解压后文件全散在根目录”的抱怨,都是这个开关写反了。

RemoveRedundantPaths 解决的是重复打包。 如果调用方同时传了 D:\logs 和 D:\logs\a.log,展开后同一文件会出现两次,压缩包里就有了重复条目。它的实现很巧:先 OrderBy 排序(父目录必然排在子目录前面),然后逐条比较——当前路径以上一条为前缀、且前缀后紧跟目录分隔符,就跳过。最后这个”紧跟分隔符”的判断很关键,否则 C:\Temp 和 C:\Template 会被误判成父子关系。

四、写压缩包:4KB 缓冲 + 增量 CRC + 可选密码

真正落盘的逻辑集中在一个内部方法里,核心是 PutNextEntry / CloseEntry 必须成对出现,且文件条目要用固定缓冲循环读取。

using var zipFileStream = File.Create(zipFilePath);
using var zipOutputStream = new ZipOutputStream(zipFileStream);

zipOutputStream.SetLevel(zipLevel);

if (!string.IsNullOrEmpty(password))
{
    zipOutputStream.Password = password;
}

var crc = new Crc32();
byte[] buffer = new byte[4096];

foreach (var zipEntryInfo in zipEntryList)
{
    var entry = new ZipEntry(zipEntryInfo.EntryName)
    {
        DateTime = zipEntryInfo.ModifyTime
    };

    if (zipEntryInfo.IsDirectory)
    {
        entry.Size = 0;
        zipOutputStream.PutNextEntry(entry);
    }
    else
    {
        using var inputFileStream = File.OpenRead(zipEntryInfo.FullPath);
        entry.Size = inputFileStream.Length;

        crc.Reset();
        zipOutputStream.PutNextEntry(entry);

        int bytesRead;
        while ((bytesRead = inputFileStream.Read(buffer, 0, buffer.Length)) > 0)
        {
            var segment = new ArraySegment<byte>(buffer, 0, bytesRead);
            crc.Update(segment);
            zipOutputStream.Write(buffer, 0, bytesRead);
        }

        entry.Crc = crc.Value;
    }

    zipOutputStream.CloseEntry();
}

zipOutputStream.Finish();

几点说明:

  • entry.Size 要提前写入。ZIP 支持在数据描述符里后补长度,但显式写入对很多老解压工具兼容性更好。
  • CRC 是增量算出来的。每读一段就 crc.Update,读完把 crc.Value 赋给 entry.Crc,全程零整文件加载。
  • 目录条目 Size = 0,只占一个名字,不写内容。
  • Finish() 不能省,它负责把中央目录(Central Directory)写进流,缺了它压缩包会打不开。

另外需要提醒:这里的密码是 ZIP 传统的 ZipCrypto 加密,强度很低,只适合”防止随手打开”,不能当作真正的数据保护。真要加密敏感文件,应该先用 AES 加密内容再打包,或者直接用带 AES 的压缩库。

五、解压:GBK 乱码、Zip Slip、时间戳三个坑

解压比压缩更容易出问题,因为要处理”别人生产的”文件。同一个方法里我解决了三件事。

问题 1:中文文件名乱码

SharpZipLib 默认按 UTF-8 解析条目名,但大量国内软件(尤其是老版本 Windows 自带压缩、某些业务系统导出的包)实际使用 GBK。表现就是解压出来的文件名全是”锟斤拷”。解决办法是把编码器显式传给 ZipInputStream:

// 注册代码页编码,否则 .NET Core/.NET 8 默认拿不到 GBK
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Encoding gbkEncoding = Encoding.GetEncoding("GBK");
var gbkCodec = StringCodec.FromEncoding(gbkEncoding);

using var zipFileStream = File.OpenRead(zipFilePath);
using var zipInputStream = new ZipInputStream(zipFileStream, gbkCodec);

注意第一行:.NET Core 之后默认只带 UTF-8 等少数编码,Encoding.GetEncoding("GBK") 会直接抛异常,必须注册 CodePagesEncodingProvider。这个注册全局一次性即可,放在静态构造或启动阶段更合适。

问题 2:Zip Slip 目录穿越

这是 ZIP 场景里最典型的安全漏洞。如果压缩包里有条目叫 ../../web.config,而解压代码直接 Path.Combine(目标目录, entry.Name),文件就会被写到目标目录之外,攻击者据此可以覆盖任意文件。

防御要做两层:

// 第一层:把条目名转成安全的相对路径,内部拒绝任何 ".."
string localName = EntryNameToLocalName(entry.Name);

// 第二层:合成绝对路径后再做前缀校验(兜底)
string destinationPath = Path.GetFullPath(Path.Combine(unZipPath, localName));
if (!destinationPath.StartsWith(unZipPath, comparison))
{
    continue;
}

EntryNameToLocalName 的逻辑和压缩时对称:转正斜杠、切段、丢弃空段和 .、遇到 .. 抛异常、最后还要检查 Path.IsPathRooted,防止条目名是 C:\Windows\... 这种绝对路径。

第二层前缀校验是”防御性冗余”。理论上第一层已经拦住,但如果哪天有人改动或绕过第一层,GetFullPath 归一化之后的前缀比较仍能兜住。安全代码值得这种冗余。

问题 3:保留修改时间

using (var fs = File.Create(destinationPath))
{
    zipInputStream.CopyTo(fs);
}

if (entry.DateTime != DateTime.MinValue)
{
    File.SetLastWriteTime(destinationPath, entry.DateTime);
}

日志、归档类文件的时间戳往往是业务信息,不还原就丢了一半价值。DateTime.MinValue 的判断是因为某些压缩包没写时间字段。

完整的遍历结构如下,可以顺便看到 CopyTo 是流到流直接拷,不经过 byte[] 中转:

ZipEntry entry;
while ((entry = zipInputStream.GetNextEntry()) != null)
{
    if (string.IsNullOrWhiteSpace(entry.Name))
    {
        continue;
    }

    string localName = EntryNameToLocalName(entry.Name);
    string destinationPath = Path.GetFullPath(Path.Combine(unZipPath, localName));

    if (!destinationPath.StartsWith(unZipPath, comparison))
    {
        continue;
    }

    if (entry.IsDirectory)
    {
        Directory.CreateDirectory(destinationPath);
    }
    else
    {
        Directory.CreateDirectory(Path.GetDirectoryName(destinationPath));

        using (var fs = File.Create(destinationPath))
        {
            zipInputStream.CopyTo(fs);
        }

        if (entry.DateTime != DateTime.MinValue)
        {
            File.SetLastWriteTime(destinationPath, entry.DateTime);
        }
    }
}

六、“只看不解”:用同一套遍历复用列表能力

有些场景只需要知道包里有什么,不想真的落盘。比如解析器要先判断包里是不是目标文件。这时把”是否执行解压”抽成一个布尔参数即可,遍历逻辑完全复用:

public static IReadOnlyList<string> UnZip(string zipFilePath, string unZipPath = null, string password = null)
    => GetZipEntries(zipFilePath, unZipPath, password, executeExtraction: true);

public static IReadOnlyList<string> GetZipFileNames(string zipFilePath, string unZipPath = null, string password = null)
    => GetZipEntries(zipFilePath, unZipPath, password, executeExtraction: false);

executeExtraction = false 时仍然会做路径安全校验并返回目标路径列表,但不写文件。这种”行为参数化”比复制一份遍历代码要稳,因为安全校验只有一份,不会被漏改。

七、魔数校验:4 个字节判断是不是 ZIP

扩展名会骗人,真正可靠的是文件头。ZIP 的标准魔数是 50 4B 03 04(也就是 PK\x03\x04):

private static bool IsRealZipFile(FileStream stream)
{
    if (stream == null || !stream.CanRead || stream.Length < 4)
    {
        return false;
    }

    // 栈上分配,零 GC 压力
    Span<byte> header = stackalloc byte[4];

    // RandomAccess 读取不会改变流的 Position
    RandomAccess.Read(stream.SafeFileHandle, header, 0);

    // 列表模式匹配,等价于逐字节比较
    return header is [0x50, 0x4B, 0x03, 0x04];
}

两个 .NET 技巧值得记住:

  • stackalloc 分配 4 字节栈内存,高频调用不会产生 byte[] 垃圾。
  • RandomAccess.Read 按偏移读取且不移动 Position,比先 Seek(0) 再读、读完还要还原位置要省事得多。

但要注意方法上的注释:.xlsx、.docx 这类 Office 文件本质上也是 ZIP,用魔数判断同样返回 true。所以要区分”是不是 ZIP 格式”和”是不是我要的业务压缩包”,还需要结合扩展名或内部条目结构判断。

八、这套封装在项目里的实际用法

工具类最终是为业务服务的,几个真实调用场景能说明它的覆盖面:

  • 附件批量下载:选中多条附件时,把每个文件的真实名称作为条目名打包,用户下载后看到的还是原名,而不是磁盘上的 GUID。
  • 现网日志打包:日志文件在磁盘上是分层目录,用 RelativePath 作为条目名,压缩包里自然还原出目录树。
  • 解析前置解压:网元采集回来的日志是 ZIP,解析器先 UnZip 到临时目录再逐文件解析;单文件场景还会用 GetUnZipedFileName 按正则从包里挑出目标文件。
var zipFiles = new List<ZipEntryInfo>(id.Length);

for (int i = 0; i < id.Length; i++)
{
    var item = WorkData.GetWorkAttachmentObject(id[i]);
    if (item == null)
    {
        continue;
    }

    string path = attachmentPath + id[i];
    var fi = new FileInfo(path);

    // 磁盘名是 GUID,条目名用业务名
    zipFiles.Add(new ZipEntryInfo(path, item.Name, fi.LastWriteTime, false));
}

SharpZipHelper.Zip(zipFiles, zipedFileName);

九、易错点清单

现象 根因 处理方式
解压后中文文件名乱码 压缩包用 GBK 编码,默认按 UTF-8 解析 注册 CodePagesEncodingProvider,用 GBK StringCodec 构造输入流
压缩包里目录丢失 目录条目名没以 / 结尾 目录条目统一补 /,文件不补
解压报”文件已损坏” 忘记调用 Finish() 写中央目录 写完所有条目后必须 Finish()
文件被写到目标目录外 未防 Zip Slip 拒绝 .. + GetFullPath 后前缀校验,双层防御
包里出现重复条目 同时传入父目录和子文件 路径排序后按前缀去重
大文件压缩内存暴涨 整文件读入 byte[] 固定 4KB 缓冲循环读写,CRC 增量计算
判断 ZIP 误伤 Excel .xlsx 本身就是 ZIP 魔数只用于格式判断,业务类型结合扩展名

十、总结

ZIP 看起来只是个”打包”动作,真要在业务系统里稳定使用,需要处理的是四类问题:格式规范(条目名、目录斜杠、中央目录)、编码兼容(GBK 与 UTF-8)、安全防护(Zip Slip、绝对路径)和资源控制(流式读写、CRC 增量、栈上缓冲)。

回头看这套封装,真正有价值的不是调了哪些 API,而是几条设计取舍:条目名与本地路径分离成两个字段,安全校验只有一份且压缩解压对称,遍历逻辑用参数控制行为而不是复制代码。把这几点想清楚,即使换成 System.IO.Compression 或别的库,也能写出同样稳的实现。