前言
业务系统里几乎绕不开
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 或别的库,也能写出同样稳的实现。