compress 与 archive — DEFLATE 家族与 zip/tar 归档
Go 标准库在"压缩"这件事上的边界比很多人以为的窄:compress/* 只有 DEFLATE 家族(flate/gzip/zlib)加一个只能解压的 bzip2,archive/* 只有 zip 和 tar 两种容器。zstd、xz、lz4、brotli 一律要走第三方。这一章讲清楚:谁能写谁只能读;gzip.Writer 的 Close 为什么是数据完整性问题而不是礼貌问题;多成员 gzip 流的默认语义;魔数嗅探的正确姿势;压缩级别的实测代价;zip 与 tar 写端的真实约束;最后是解压他人上传的归档时必须防的 zip-slip / tar-slip。
本章的错误文本、字节数、耗时均为 go1.27.0 / Windows 11 实测(benchmark 机器 i7-10750H),引用 GOROOT 源码处注明文件与行号;与平台相关的判定差异(Windows 保留名、路径分隔符)会明确标出。
1. 能力边界:谁能写,谁只能读
go doc compress/bzip2 的包注释原文是 “Package bzip2 implements bzip2 decompression”,导出面只有 NewReader(r io.Reader) io.Reader 和一个 StructuralError——bzip2 只有 Reader,没有 Writer。这不是文档没写全,go doc 列出的就是全部:想产出 .bz2 只能调外部工具(tar -cj)或第三方库。
其余各包的真实能力:
compress/flate:裸 DEFLATE(RFC 1951),无头无尾,没有任何魔数,读回来的唯一办法是双方事先约定。压缩与解压都有(NewWriter/NewReader)。compress/gzip:DEFLATE 内核 + 10 字节头 + 8 字节尾(CRC32 + 明文长度模 2^32,RFC 1952)。支持多成员串接(见第 3 节)。compress/zlib:DEFLATE 内核 + 2 字节头 + Adler-32 尾(RFC 1950)。默认级别下头两字节实测是78 9c。archive/zip:容器 + 每条目自选 Store(method 0,不压缩)或 Deflate(method 8)。读写齐全,还能当fs.FS用((*Reader).Open)。archive/tar:纯打包器,本身不压缩;.tar.gz 是 tar 流套在 gzip 流里手工拼出来的(见第 9 节)。
标准库没有的格式:zstd、xz、lz4、brotli、7z。工程上最常见的补充是 github.com/klauspost/compress(zstd/lz4/flate 的高速实现)与 github.com/ulikunitz/xz、github.com/andybalholm/brotli。选型时"是否允许引入第三方"常常就是 gzip 与 zstd 之间的第一道取舍:gzip 的压缩率和速度在今天都已不算出色,唯一优势是零依赖、到处能被识别。
底层原理:三兄弟只是同一内核的三种外壳
flate.NewWriter 产出的字节流,就是 gzip 与 zlib 流中间那段 DEFLATE 压缩数据(gzip 文档明说 Reader “uses the flate package internally”)。三者的差别只在信封:flate 什么都不包,zlib 包一层 2 字节头(其中含检查字典标识)和 Adler-32 尾,gzip 包 RFC 1952 的头尾并额外给出 CRC32。所以 compress/flate 适合"对方协议里已经有自己的成帧"(如 PNG、TLS 记录层、SSH)的场景,日常落盘文件永远该选 gzip/zlib 这类自描述格式——裸流一旦截断,读端连"从哪开始坏"都判断不出。
2. gzip.Writer:Close 不是礼貌问题,忘 Close 丢的是尾部
gzip 尾部 8 字节是 CRC32 + 明文总长度。Close() 的职责就是把 flate 内部缓冲区剩余数据排空、写出这 8 字节尾。忘 Close,写出去的文件头是完整的(数据甚至可能已随缓冲区刷出一部分),但尾部缺失——读端读到数据尽头拿不到校验,io.ReadAll 实测报:
unexpected EOF实测过程(go1.27.0):向 gzip.Writer 写入 12 字节 "hello, world" 后不 Close,整个缓冲区里只有 10 个字节——恰好是 10 字节 gzip 头(1f 8b 08 00 00 00 00 00 04 ff),压缩数据还压在 flate 的 buffer 里一个字节都没出来。此时 gzip.NewReader 不报错(头合法),错误在 ReadAll 时才爆。这就是为什么写端 Close 必须查错:
f, err := os.Create("app.log.gz")
if err != nil {
return err
}
defer f.Close()
gw := gzip.NewWriter(f)
gw.Write(payload) // 错误处理同理省略
if err := gw.Close(); err != nil { // 尾部(CRC+长度)在这里写出,磁盘满/写错误在这现形
return err
}defer gw.Close() 直接写会把 Close 的错误丢掉,坚持 defer 的话至少包一层把错误记进日志:
defer func() {
if err := gw.Close(); err != nil {
log.Printf("gzip close failed, archive unusable: %v", err)
}
}()Flush 与 Close 的区别
Flush() 只做一个 sync flush:把已缓冲的压缩数据刷出去并追加 00 00 ff ff 同步标记,不写尾部。实测:写 12 字节后 Flush 不 Close,流长 32 字节、尾部是 00 00 ff ff;用默认 Reader ReadAll 能拿到完整数据 "hello, world",但错误仍是 unexpected EOF——数据可读,校验不完整,多数工具会拒绝这个文件。配合 Multistream(false) 读时,Flush 点就是成员边界,实测一次 Read 返回 n=12 err=<nil>,随后 io.EOF。Flush 的正确用武之地是流式协议(日志实时推给对端),落盘归档必须走 Close。
3. 多成员流:Multistream 的默认行为与 Header 陷阱
RFC 1952 允许把多个独立 gzip 成员首尾相接,“读起来等价于把明文串起来再压一次”。Go 的 gzip.Reader 默认就是多成员模式(Multistream 文档原文:“If enabled (the default)…")。实测:连续写入两个成员(各 4 字节明文,Name 分别设为 first.txt/second.txt,整流 79 字节),默认 Reader 一次 io.ReadAll 直接拿到 "AAAABBBB"、err=<nil>,跨成员无感知。
但跨成员时 Header 不会跟着更新。文档明说 “Only the first header is recorded in the Reader fields”,实测 ReadAll 把第二个成员也读完之后再取 zr.Name,仍是 "first.txt"——想逐成员处理元数据,必须显式关闭多成员模式、用 Reset 手工推进:
zr.Multistream(false)
for {
data, err := io.ReadAll(zr) // data、zr.Name、zr.Comment 对应当前成员
if err != nil {
return err
}
_ = data
if err := zr.Reset(r); err != nil { // 实测:没有下一成员时 Reset 返回 EOF
break
}
zr.Multistream(false) // Reset 会把行为复位为默认的多成员模式
}实测逐成员读取输出:member 0: name="first.txt" data="AAAA"、member 1: name="second.txt" data="BBBB"、Reset -> EOF (no more members)。两个边界情况:NewReader 喂空输入报 EOF;喂裸 flate 数据(无头)报 gzip: invalid header。日志按小时滚动 gz 后直接 cat 拼接、下游用 gzip Reader 透明消费,这套玩法在 Flume/Filebeat 一类管道里很常见,知道默认串接行为才不会被"看起来坏了的 gz"吓到。
4. 魔数嗅探:gzip/zip 能认,其余要自己搭表
收到一段来路不明的字节流,第一步往往是"判断它是什么格式”。下表是实测的前几个字节(gzip/zlib/zip/tar 由标准库写端现场生成;bzip2/xz/zstd/7z 样本来自外部工具,字节真实但标准库没有对应写端):
| 格式 | 起始字节实测 | 含义 | http.DetectContentType 结果 |
|---|---|---|---|
| gzip | 1f 8b 08 00 |
魔数 1f 8b + 方法字节(8 = deflate) |
application/x-gzip |
| zlib | 78 9c |
78 = deflate 窗口编码,9c 含校验位;默认级别实测值 |
application/octet-stream |
| bzip2 | 42 5a 68 39 |
ASCII BZh9,9 是块大小档(900 KB) |
application/octet-stream |
| zip | 50 4b 03 04 |
PK + 03 04,本地文件头签名 |
application/zip |
| tar | 无起始魔数 | 前 100 字节就是文件名字段;偏移 257 起为 ustar 加一个 NUL,实测 75 73 74 61 72 00 |
application/octet-stream |
| flate(裸) | 无 | 同一 payload 实测 ca 48 cd c9,随内容与级别变化 |
application/octet-stream |
| xz / zstd / 7z | fd 37 7a 58 5a / 28 b5 2f fd / 37 7a bc af |
标准库不读写这些格式,列出来供完整表 | 均为 application/octet-stream |
http.DetectContentType 只认 gzip 和 zip 两种归档——它的目标是定 HTTP Content-Type,不是归档嗅探器,别指望它。自定义嗅探用 bufio.Reader.Peek,它允许"看过但不取走":
func sniff(r *bufio.Reader) (string, error) {
p, err := r.Peek(4)
if err != nil {
return "", err
}
switch {
case p[0] == 0x1f && p[1] == 0x8b:
return "gzip", nil
case p[0] == 'B' && p[1] == 'Z' && p[2] == 'h':
return "bzip2", nil
case p[0] == 'P' && p[1] == 'K':
return "zip", nil
}
// tar 没有起始魔数但 ustar 签名固定在第 257 字节,这个检查必须排在 zlib 前面
if full, err := r.Peek(512); err == nil && len(full) == 512 && bytes.HasPrefix(full[257:], []byte("ustar")) {
return "tar", nil
}
// zlib 头要求前两字节 (CMF<<8|FLG) 能被 32 整除,Go 默认级别是 78 9c
if p[0] == 0x78 && (uint16(p[0])<<8|uint16(p[1]))%32 == 0 {
return "zlib", nil
}
return "unknown", nil // 裸 flate 无法嗅探,只能靠约定
}这段代码的分支顺序是实测调出来的:最初 zlib 分支只看首字节 0x78 且排在前面,结果一个首条目名以 x 开头的 tar 包被误判成 zlib——tar 首字节就是文件名,撞车无法避免,所以 ustar 检查必须先行,zlib 再叠加上面的整除校验。调整后在标准库样本上 gzip/zip/bzip2/tar 各归其位,裸 flate 落入 unknown。tar 分支的前提是"块对齐",如果流实际是 .tar.gz,ustar 藏在 gzip 信封之下,嗅探永远要先剥外层。
5. 压缩级别:时间换压缩率的实测账
flate 包的级别常量(GOROOT compress/flate/deflate.go):HuffmanOnly=-2、NoCompression=0、BestSpeed=1 … BestCompression=9、DefaultCompression=-1——-1 在源码里直接映射为 6(deflate.go 第 731 行 level = 6)。用 1 MiB(实测 1,048,578 字节)可重复的伪造访问日志做语料,go test -bench -benchtime=2s 实测(i7-10750H / go1.27.0):
BenchmarkGzipBestSpeed-12 754 3271645 ns/op 320.50 MB/s 176581 out_B 5.938 x
BenchmarkGzipDefault-12 338 7193503 ns/op 145.77 MB/s 162680 out_B 6.446 x
BenchmarkGzipBestCompression-12 39 53463931 ns/op 19.61 MB/s 145356 out_B 7.214 xout_B 是压缩后字节数,x 是压缩比。三个读法:BestSpeed 比 Default 快 2.2 倍(3.3 ms 对 7.2 ms),产物只大 8.5%;BestCompression 比 Default 再省 10.6%,却要 7.4 倍的时间(1 MiB 花掉 53 ms,即只有 19.6 MB/s)。高级别在 flate 里多花的时间主要买"更深的匹配搜索与更优的块划分",但窗口固定在 32 KiB(windowSize = 1 << 15),语料的长程重复超出窗口就无从利用——这就是级别开到 9 也压不过 zstd 大窗口的结构性原因。工程结论:在线链路(接口响应、日志实时投递)选 BestSpeed 或默认,冷数据归档选 BestCompression 但要确认能接受吞吐只有前者的 6%;已经过 LZ 压缩的数据再套 flate 用 HuffmanOnly(源码注释:适合喂给"已有 LZ 结果但缺熵编码"的数据)。
6. archive/zip:写端只要求 io.Writer,读端才要求 io.ReaderAt
先纠正一个流传很广的说法:zip.NewWriter 不需要 io.WriterAt,bytes.Buffer 完全能收。实测:向 bytes.Buffer 写一个 zip,Close 返回 <nil>,产出 168 字节并用 zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) 原样读回。原因在 GOROOT archive/zip/writer.go:非 raw 条目统一置 Flags |= 0x8(data descriptor,第 375 行),本地文件头先写占位零值(第 470 行 b.uint32(0) // crc32),CRC 与两个 size 在条目收尾时才填进 FileHeader(第 656-657 行),最终随中央目录一起顺序追加——全程不回头 seek,任何 io.Writer 够用,网络流直接当 zip 容器也成立。
需要 io.ReaderAt 的是读端,因为 zip 的目录在文件尾部,必须随机访问。类型不满足是编译期错误,实测文本:
cannot use &buf (value of type *bytes.Buffer) as io.ReaderAt value in argument to zip.NewReader: *bytes.Buffer does not implement io.ReaderAt (missing method ReadAt)解法二选一:bytes.NewReader(buf.Bytes()),或直接用 *os.File(它同时满足 Reader/ReaderAt/Writer/WriterAt)。zip.OpenReader(path) 是"帮你打开并定位好"的糖,它持有文件句柄,必须 Close;只用 zip.NewReader 时底层文件归你关。格式不对时的统一错误是 zip.ErrFormat,实测把一个 gzip 头的假 .zip 喂给 zip.OpenReader 得到:zip: not a valid zip file。
条目收尾方式与 gzip 不同,这是 zip 最容易被肌肉记忆坑到的地方:Create/CreateHeader 返回的是 io.Writer,根本没有 Close 方法(go doc archive/zip.Writer.Create 签名即 io.Writer)。条目由"下一次 Create/CreateHeader/CreateRaw 或 Writer.Close“自动收尾(文档原话 “The file’s contents must be written to the io.Writer before the next call to…";兜底逻辑在 writer.go 第 83-87、273-274 行)。实测:写完 27 字节不"Close”(也无从 Close),zw.Close() 返回 nil,重新打开该包条目 size=27、内容完好。真正的约束是:收尾之后再往旧 writer 写会报 zip: write to closed file(writer.go 源码文本),而 CreateHeader 之后不要再改传入的 FileHeader(文档:“Writer takes ownership of fh and may mutate its fields”)。写端骨架:
f, _ := os.Create("out.zip")
zw := zip.NewWriter(f)
zw.SetComment("archive-level comment")
hdr := &zip.FileHeader{Name: "docs/readme.md", Method: zip.Deflate,
Modified: time.Date(2026, 10, 14, 8, 30, 0, 0, time.UTC)}
hdr.SetMode(0o644) // 把 Unix 权限位写进外部属性,非 Unix 平台上唯一可靠通道
w, err := zw.CreateHeader(hdr) // 返回 io.Writer,无 Close 方法
if err != nil { return err }
io.WriteString(w, "# hello zip\n") // 必须在下一次 Create/Close 前写完
zw.CreateHeader(&zip.FileHeader{Name: "empty-dir/", Method: zip.Store}) // 名字以 / 结尾即目录条目,可再 SetMode(os.ModeDir | 0o755)
if err := zw.Close(); err != nil { // 写中央目录,zip 文件才算完成
return err
}
f.Close()实测另两个细节。其一,小文件用 Deflate 可能越压越大(实测 12 字节文本变 19 字节,方法 8 的 Huffman 表有固定开销),对已知不可压的内容用 zip.Store。其二,Modified 时区是会往返的,比"只存 DOS 墙钟"的说法准:头里的 MS-DOS 字段确实只存墙钟,但写端总是额外出一个 extended timestamp 扩展字段,读端优先采用它——struct.go 字段注释原话:“When writing, an extended timestamp (which is timezone-agnostic) is always emitted. The legacy MS-DOS date field is encoded according to the location of the Modified time”;“When reading, an extended timestamp is preferred over the legacy MS-DOS date field, and the offset between the times is used as the timezone. If only the MS-DOS date is present, the timezone is assumed to be UTC”。实测(go1.27.0):写 time.Local(UTC+8)的 12:13:15.5 读回 2026-10-14 12:13:15 +0800 +0800(时区经扩展字段还原,重建出的 Location 就是那个 offset 本身;亚秒被截掉,DOS 场更是只有 2 秒粒度);写 UTC 08:30 读回 08:30:00Z;留零值读回 1979-11-30 00:00:00 +0000 UTC。第三方工具产出的包若没带扩展字段,按注释一律按 UTC 解析。实用口径:比较时间戳用绝对时刻(Equal/Unix()),别假设读回的 Location 名字与写入端一致;要归档口径统一,先 .UTC() 再写。
条目名规则要点:Create 文档要求 “must be a relative path… only forward slashes are allowed”,但写端既不校验也不转换——实测把 a\b.txt 原样存进包里(读回仍是 "a\\b.txt"),把 /etc/passwd 传进 CreateHeader 也返回 nil 接受。所以打包本机目录前先 filepath.ToSlash(rel),别让用户输入直接当条目名。读端 *Reader 实现了 fs.FS(Open 方法),拿到归档可以直接 fs.WalkDir(zr, ".") 遍历,配合 embed.FS 那套用法无缝迁移。
CreateRaw(把第三方算法已压好的字节原样装包,配合 FileHeader.Method)还有一条只有它会踩的不对称:条目的大小与 CRC 全由调用方负责。实测只设 Method: 99 写了 9 字节、不填 UncompressedSize64/CompressedSize64/CRC32,CreateRaw、Write、Close、重新 NewReader 一路全是 nil,条目却静默读回空串;把三个字段填齐、读端再 RegisterDecompressor(99, func(r io.Reader) io.ReadCloser { ... }),原字节才能往返(均实测)。用 CreateHeader + 标准 Deflate/Store 没有这个负担——那是写端的活。
7. archive/tar:所有元数据都要自己填,Size 填错立刻翻车
tar 是"头部块 + 数据块"的严格协议,写端没有猜测逻辑:WriteHeader(&tar.Header{...}) 里 Size 必须等于接下来写入的字节数,Typeflag 决定条目语义。实测三种错法:
- 声明
Size:10只写了 5 字节:本次Write不报错(n=5 err=<nil>),下一次WriteHeader或Close才报archive/tar: missed writing 5 bytes,且错误粘滞——后续所有操作都返回同一句。 - 声明
Size:3却写 6 字节:Write当场返回n=3, err=archive/tar: write too long(多出的字节被拒绝)。危险的尾巴是:此时Close()返回<nil>——如果代码不检查 Write 的错误,一个内容被截断的"合法"包就这么出门了。 - 只
WriteHeader不写数据就Close:archive/tar: missed writing 50 bytes。
结论:tar 写端每个能返回 error 的调用都得检查,尤其是 Write。
读端是标准 Next 循环,io.EOF 收尾;Next 之后 Read 只在当前条目内取数,取完自动到下一块边界:
tr := tar.NewReader(f)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err // 截断/校验失败的包在这里现形
}
switch hdr.Typeflag {
case tar.TypeReg:
io.Copy(dst, tr) // Size 由 Header 给出,直接拷
default: // TypeDir('5')/TypeLink('1')/TypeSymlink('2') 等没有数据体,见下
}
}手写 Header 麻烦且容易填错,从真实 FileInfo 出发用 tar.FileInfoHeader(fi, link):实测对普通文件得到 name="sample.log" size=14 typeflag='0',目录得到 '5',TypeLink 手写时带 Linkname 读回 type='1' link="sample.log" size=0(硬链接条目没有数据体,读端拿 Linkname 指向已解出的文件);稀疏文件另有 TypeGNUSparse('S',common.go 第 91-92 行 “a sparse file in the GNU format”),tar.Reader 能解 PAX/GNU 稀疏扩展并把空洞还原成数据。两个坑:FileInfoHeader 生成的 Name 只是 fi.Name(),相对路径要自己拼并 filepath.ToSlash;Windows 上 stat 出来的权限是 mode=438(即 0o666)——Windows 文件系统不保存 Unix 权限位,归档里这些位到了 Linux 也只是装饰。
Format 字段决定头部编码,留 FormatUnknown 即 USTAR/GNU/PAX 自动挑选。实测(FormatAutomatic,读回 hdr.Format 验证):普通 ASCII 短名 → USTAR;124 字符长名 → PAX(USTAR 的 name 字段只有 100 字节);中文名"订单日志-2026.txt” → PAX(USTAR 字段按字节存 ASCII 语义);Uid:70000 仍在 USTAR 容量内(八进制 8 位);带纳秒的 ModTime 在 USTAR 下静默截断到秒(实测 456789000ns 读回 0)——需要亚秒精度必须强制 PAX。和 zip 一样,tar 写端不校验条目名:实测 WriteHeader("../t.txt") 与 /abs/t.txt 都返回 nil。打包器只是搬运工,防逃逸的责任百分百落在解包方——这正是下一节的主题。
8. zip-slip / tar-slip:filepath.Join 拼出来的解包代码是给上层目录开门
归档里的条目名是攻击者可控的字符串,而解包最常见的朴素写法是:
target := filepath.Join(dest, hdr.Name) // zip 端是 f.Name,tar 端是 hdr.Name
os.Create(target)filepath.Join 内部先 Clean,而 Clean("../evil.txt") 还是 ../evil.txt——拼接后再规范化等于没规范化,.. 依旧指向 dest 之外。实测:手工造一个 zip,条目依次为 ../escaped.txt、docs/../../escaped2.txt、ok.txt(写端全部照收,CreateHeader 一个错都不报),用上面写法解进临时目录 base/out,结果 escaped.txt 和 escaped2.txt 都真实落在了 base/(解包目录的上层),os.Stat 复核 err=<nil>。tar 一模一样:../escaped_tar.txt 条目读出来 IsLocal=false,朴素拼接同样逃逸。绝对路径与 Windows 变体(C:/Windows/...、UNC \\server\share\...)一样危险,因为 Join 把绝对分量当普通字符串吞。
两件正确工具。其一:filepath.IsLocal,Go 1.20 加入(GOROOT api/go1.20.txt:path/filepath, func IsLocal,常被误记成 1.24)——判定"这个名字还留在求值目录里吗",纯词法:先规范化、再看是否逃逸/绝对/空。实测(go1.27.0 / Windows;\ 相关项在 Linux 语义不同):
filepath.IsLocal("a/") // true
filepath.IsLocal("a//b") // true,空段在规范化时被去掉
filepath.IsLocal(".") // true,指向自身不算逃逸
filepath.IsLocal("..") // false
filepath.IsLocal("../evil.txt") // false
filepath.IsLocal("/etc/passwd") // false
filepath.IsLocal("") // false
filepath.IsLocal("NUL") // false,Windows 保留设备名
filepath.IsLocal("com1") // false,同上
// Windows 实测补充:C:\x 与 UNC \\server\share\x 均 false;a\..\b.txt 规范化成 b.txt,true两条边界必须知道:IsLocal 不检测内嵌 NUL(0x00)字节——实测含 0x00 的名字返回 true,别把它当白名单直接放行;它防的是"名字"的逃逸,防不了归档里的符号链接条目——tar 的 TypeSymlink 可以把链接目标指向任意位置(Linkname 不在 IsLocal(hdr.Name) 的管辖内),解包时要么拒绝创建符号链接,要么对 Linkname 再做一次判定(归档风格路径用 path.IsLocal),否则防护不完整。
其二:os.OpenRoot(dest) 的 Root,Go 1.24 加入(api/go1.24.txt:os, func OpenRoot)——内核语义的"只开这扇门"。Root 的每个方法都基于目录的文件描述符做 openat 式解析,组件越界当场拒绝。实测:root.Open 报 openat ../out.bin: path escapes from parent,root.MkdirAll 报 mkdirat ..: path escapes from parent,root.Stat("NUL") 报 statat NUL: path escapes from parent。错误类型是 *fs.PathError:Op 是底层动作,Err 的文本是 “path escapes from parent”——但它不是哨兵错误,实测复核 errors.Is(err, os.ErrInvalid) 与 errors.Is(err, fs.ErrInvalid) 均为 false,想程序化识别只能 errors.As 拿 *fs.PathError 再匹配文本。go doc os.Root 明确:符号链接会被跟随,但根内符号链接不得指向根外、不得是绝对路径;Windows 上额外禁用保留设备名。把同一个恶意 zip 交给 Root 解包:前两个条目被拦,ok.txt 正常落盘。两者配合使用:IsLocal 便宜、可逐条拒绝并记审计日志;Root 兜底,把"忘了检查"变成"写不进去"。
注意:Go 1.20 之前两个都没有(
os.Root更是到 1.24 才有),网传的strings.HasPrefix(最终路径, dest)有缺口——Windows 不比较大小写与分隔符、dest自己可能带..。老版本项目至少做filepath.Clean后用filepath.Rel判定首分量是否..,并拒绝绝对条目。
9. tar.gz 实战:打包一组日志并安全解包
archive/tar + compress/gzip + os 三件套最常见的用途是日志归档备份。完整可跑程序如下,pack 的要点是 Close 顺序,unpack 的要点是第 8 节的防护:
func pack(dir, destGz string) error {
f, err := os.Create(destGz)
if err != nil {
return err
}
defer f.Close()
gw := gzip.NewWriter(f)
tw := tar.NewWriter(gw)
err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
fi, err := d.Info()
if err != nil {
return err
}
hdr, err := tar.FileInfoHeader(fi, "")
if err != nil {
return err
}
rel, _ := filepath.Rel(dir, p)
hdr.Name = filepath.ToSlash(rel) // tar 条目名一律用 /
if err := tw.WriteHeader(hdr); err != nil {
return err
}
src, err := os.Open(p)
if err != nil {
return err
}
defer src.Close()
_, err = io.Copy(tw, src) // Size 由 FileInfoHeader 填好,无需手算
return err
})
if err != nil {
return err
}
if err := tw.Close(); err != nil { // 先关 tar:尾部两个零块要写进 gzip
return err
}
return gw.Close() // 再关 gzip:写 CRC/长度尾
}
func unpack(gzPath, destDir string) error {
f, err := os.Open(gzPath)
if err != nil {
return err
}
defer f.Close()
gr, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gr.Close()
root, err := os.OpenRoot(destDir) // Go 1.24:解包基座
if err != nil {
return err
}
defer root.Close()
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if !filepath.IsLocal(hdr.Name) { // 先显式拒绝,给出可读的审计错误
return fmt.Errorf("refuse unsafe entry name %q", hdr.Name)
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := root.MkdirAll(filepath.FromSlash(hdr.Name), 0o755); err != nil {
return err
}
case tar.TypeReg:
name := filepath.FromSlash(hdr.Name)
if err := root.MkdirAll(filepath.Dir(name), 0o755); err != nil {
return err
}
out, err := root.Create(name)
if err != nil {
return err
}
_, err = io.CopyN(out, tr, hdr.Size) // 尊重声明大小,别超写
out.Close()
if err != nil {
return err
}
default: // 符号链接/硬链接等:不信任的归档直接跳过
fmt.Printf("skip typeflag %v: %s\n", hdr.Typeflag, hdr.Name)
}
}
}实测:把含 app.log(14,000 字节)与 2026/audit.log(9 字节)的目录打包,成品 239 字节——语料是同一行重复 500 遍,正中 flate 长距离匹配的下怀;解包无错误、结构完整还原。
为什么必须先关 tar 再关 gzip:tar.Writer.Close 要往它的 io.Writer(即 gzip)里写结束标记——两个 512 字节零块;gzip.Writer.Close 则要把 deflate 缓冲排空并写 CRC/长度尾。写反顺序时实测 gw.Close() 先成功、随后 tw.Close() 报 flate: closed writer。更阴险的是后果:坏文件的外层 gzip 完全合法(CRC 按已刷出的数据计算,读端 io.Copy 返回 nil),tar 内容却缺了结束块、静默截断——不检查 tw.Close() 错误的话,半成品归档会无声无息出门。多层包装的 Close 永远按"从内层到外层"排序,这个规则对 zip/tar/flate 组合同样成立。
10. 陷阱汇总表(每条均有实测或 GOROOT/官方文档支撑)
| 陷阱 | 实测后果 / 正确姿势 |
|---|---|
| gzip/zlib/flate Writer 忘 Close | 尾部缺失,读端 unexpected EOF;Close 后写入报 flate: closed writer |
defer gw.Close() 吞错误 |
归档坏了而日志无痕;Close 必须查错 |
| 多成员流逐成员读元数据 | Header 只记第一个成员(文档原文);要 Multistream(false) + Reset |
| 只 Flush 不 Close 就落盘 | 数据可读、尾校验缺,多数工具拒收;落盘必须 Close |
以为 zip 写端要 io.WriterAt |
恰恰相反:NewWriter 只要 io.Writer(bytes.Buffer 实测可用);NewReader 才要 io.ReaderAt,传 *bytes.Buffer 是编译错误 |
| 拿 gzip 肌肉记忆去 Close zip 条目 writer | Create 返回 io.Writer 没有 Close;条目由下一次 Create/Writer.Close 自动收尾,收尾后再写报 zip: write to closed file |
zip 条目名含 \ 或绝对路径 |
写端原样照收不转换(实测 /etc/passwd 也接受);自己 filepath.ToSlash,解包端当敌意输入 |
CreateRaw 忘填 UncompressedSize64/CompressedSize64/CRC32 |
写端零报错,条目内容静默丢失(实测读回空串);读端还需 RegisterDecompressor |
zip Modified 留零值/带时区 |
扩展时间戳总被写出、读端优先:时区往返、亚秒截到秒;零值读回 1979-11-30 UTC;统一先 .UTC() 再写 |
tar Size 与实写不符 |
少写 → archive/tar: missed writing N bytes 粘滞;多写 → write too long 之后 Close 仍返回 nil |
| tar 手填 Header 漏 Mode/Typeflag | 用 tar.FileInfoHeader 起步;目录/硬链接/稀疏对应 TypeDir(‘5’)/TypeLink(‘1’)/TypeGNUSparse(‘S’),后两类无数据体;Windows 上 Mode 只是 438 装饰 |
filepath.Join(dest, 条目名) 解包 |
zip-slip/tar-slip:实测逃逸到上层;IsLocal(Go 1.20)+ os.Root(Go 1.24),二者都防不了符号链接条目;别指望标准库产出 bz2(只有 Reader)或 zstd/xz/lz4/brotli(库外) |
用 os.ErrInvalid 匹配 os.Root 错误 |
实测 errors.Is 为 false;只能 errors.As(*fs.PathError) 匹配文本 |
实战建议
格式选型的三问:只有一个流——gzip(管道友好,Multistream 让日志滚动天然安全);多个文件、需要随机取单个条目——zip(中央目录即索引,*zip.Reader 直接实现 fs.FS,fs.WalkDir 可用);要在 Unix 工具链里流转、或对整棵树统一压缩率敏感——tar.gz。备份/日志归档的默认推荐就是本文第 9 节:tar.gz + 显式双层 Close 查错 + os.Root 解包;追求更高压缩率再考虑第三方 zstd,标准库内 flate 的 32 KiB 窗口决定了上限。
处理用户上传的归档,默认当作敌意输入:条目名(../、绝对路径、Windows 盘符与保留名)过 IsLocal;符号链接/硬链接/设备条目直接跳过或二次校验 Linkname;解包基座用 os.Root;对写入量加 io.CopyN(out, tr, hdr.Size) 之类的上限防解压炸弹——gzip 的压缩比随语料重复度上升极快,第 9 节实测 14,009 字节的日志压成 239 字节,精心构造的语料能把反向膨胀推到六个数量级,对上传文件不设闸等于把磁盘交出去。
与相邻章节的边界:os.Root、filepath.IsLocal 在真实文件系统上的更多行为见 ./os-file.md 与 ./path-filepath.md;需要 zstd/xz 等标准库外格式、或在批量脚本里直接调用系统 tar/7z 命令(exec.Command 组管道、处理退出码与 context 超时)见 ./os-exec.md;流式解压缩的 io.Pipe/bufio 配合见 ./io-interfaces.md 与 ./bufio.md。