Skip to content
Go
compress 与 archive — DEFLATE 家族与 zip/tar 归档

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 x

out_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。