← 返回题目列表

Python 怎么读写 zip 和 tar 归档?解压时有什么安全风险?

中等 第 19 / 27 题 更新于 2026/07/31
zipfiletarfile归档ZipSlip

简化版

zipfiletarfile 是标准库处理「归档文件」的两个模块——归档是「把多个文件打包成一个文件」,压缩是「把数据变小」,zip 两件事一起做,tar 只负责打包(压缩靠外挂 gzip/bz2/xz,所以叫 .tar.gz)。用法都是上下文管理器:with zipfile.ZipFile("a.zip", "w", zipfile.ZIP_DEFLATED) as z: z.write("f.txt")with tarfile.open("a.tar.gz", "w:gz") as t: t.add("dir/");读取用 namelist()/getnames() 看内容,extractall(path) 解压,open()/extractfile() 不落地直接读某个成员最重要的是安全问题:归档里的成员名可以是 ../../etc/passwd 或绝对路径,直接 extractall()把文件写到目标目录之外(这就是 ZipSlip / 路径穿越漏洞);tar 还能包含软链接、设备文件、setuid 位,危害更大。Python 3.12 起 tarfile 加了 filter= 参数"data" 是最安全的过滤器),3.14 起默认就是 filter="data";老版本必须自己逐个成员校验路径。此外还有 zip 炸弹(几十 KB 解压出几十 GB)——要先检查 ZipInfo.file_size 的总和。核心记忆:zip 打包+压缩一体、tar 打包与压缩分离;永远不要直接对不可信归档调用 extractall()

详细版

两个模块对照

维度zipfiletarfile
定位归档 + 压缩一体只归档,压缩靠 gzip/bz2/xz
常见扩展名.zip.tar / .tar.gz / .tgz / .tar.bz2 / .tar.xz
打开ZipFile(p, "r"/"w"/"a"/"x")tarfile.open(p, "r:gz"/"w:gz"/"r:*")
压缩方式ZIP_DEFLATED/ZIP_BZIP2/ZIP_LZMA每个成员独立压缩整个 tar 流整体压缩
随机访问✅ 有中央目录,可单独读某个文件.tar.gz 必须顺序解压到目标位置
保留元信息有限(修改时间、部分权限)完整(权限、属主、软链接、设备文件)
平台Windows 生态常见Unix 生态标准
成员列表namelist() / infolist()getnames() / getmembers()
import zipfile, tarfile, os
from pathlib import Path

# ① 写 zip(★不指定 compression 默认是 ZIP_STORED=不压缩★)
with zipfile.ZipFile("out.zip", "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as z:
    z.write("report.csv")                       # 归档内路径 = 原路径
    z.write("data/report.csv", arcname="report2.csv")   # ★arcname 指定归档内的名字★
    z.writestr("meta.json", '{"v":1}')          # 直接写字符串,不需要真实文件

# ② 读 zip:不落地直接读某个成员
with zipfile.ZipFile("out.zip") as z:
    print(z.namelist())                          # ['report.csv', 'report2.csv', 'meta.json']
    info = z.getinfo("meta.json")
    print(info.file_size, info.compress_size)    # 原始大小 / 压缩后大小
    with z.open("meta.json") as f:               # ★流式读取,不解压到磁盘★
        print(f.read().decode())

# ③ 写 tar.gz
with tarfile.open("out.tar.gz", "w:gz") as t:
    t.add("data/", arcname="data")               # 递归加整个目录

# ④ 读 tar:r:* 自动识别压缩方式
with tarfile.open("out.tar.gz", "r:*") as t:
    print(t.getnames())
    f = t.extractfile("data/report.csv")         # 返回文件对象(目录成员返回 None)
    if f: print(f.read()[:50])

# ⑤ ★安全解压(关键)★
# Python 3.12+:用 filter 参数
with tarfile.open("untrusted.tar.gz") as t:
    t.extractall("outdir", filter="data")        # ★最严格:拒绝绝对路径/../、软链接、特殊文件★

# 通用写法(zip 和老版本 tar 都适用):逐个成员校验
def safe_extract_zip(zip_path, dest):
    dest = Path(dest).resolve()
    with zipfile.ZipFile(zip_path) as z:
        total = sum(i.file_size for i in z.infolist())
        if total > 1024**3:                      # ★防 zip 炸弹:限制解压后总大小★
            raise ValueError(f"解压后过大: {total}")
        for info in z.infolist():
            target = (dest / info.filename).resolve()
            if not target.is_relative_to(dest):  # ★Python 3.9+;核心校验★
                raise ValueError(f"非法路径: {info.filename}")
        z.extractall(dest)

# ⑥ 攻击样例:构造一个会写到目标目录外的 zip
with zipfile.ZipFile("evil.zip", "w") as z:
    z.writestr("../../evil.txt", "pwned")        # ★extractall 会真的写到上两级目录★

⚠️ 三个必须记住的点:① 默认不压缩——ZipFile(p, "w") 不传 compression 时用的是 ZIP_STORED(只打包不压缩),生成的文件和原始数据一样大,这是最常见的「为什么我的 zip 没变小」。要压缩必须显式写 compression=zipfile.ZIP_DEFLATED。② extractall() 对不可信输入是危险操作:归档成员名可以是 ../../../etc/cron.d/x 或绝对路径 /etc/passwd,解压时会写到目标目录之外(ZipSlip 路径穿越);tar 还能携带软链接(先建一个指向 /etc 的链接,再往链接里写文件)、设备文件setuid 位。Python 3.12 起 tarfile.extractall(filter="data") 能挡住这些,3.14 起 filter="data" 成为默认zipfile 至今没有内置过滤器,必须自己校验。③ .tar.gz 不能随机访问:整个 tar 流被当作一个整体压缩,想读中间某个文件必须从头解压到那个位置——所以「归档里有一万个文件、只想取一个」的场景应该用 zip(有中央目录,可以直接定位)。

完整版教学

一、归档 vs 压缩:为什么有 .tar.gz 这种双扩展名

两件不同的事:
  归档(archive):把 N 个文件+目录结构 打包成 1 个文件   → 解决"传输/存储多文件"
  压缩(compress):把数据变小                            → 解决"体积"

  zip  :★两件事一起做★(每个成员单独压缩后放进容器)
  tar  :★只做归档★("tape archive",磁带时代的产物,就是把文件首尾相接)
  gzip :★只做压缩★(只能压一个数据流,不认识"多文件")

  所以 Unix 的组合是:tar 先打包 → gzip 再整体压缩 → 得到 .tar.gz(简写 .tgz)

关键差异由此而来:
  ┌────────────────┬──────────────────────┬────────────────────────┐
  │                │ zip(各成员独立压缩)   │ tar.gz(整体压缩)       │
  ├────────────────┼──────────────────────┼────────────────────────┤
  │ 随机访问        │ ✅ 有中央目录,直接定位 │ ❌ 必须从头顺序解压      │
  │ 压缩率          │ 略低(无法跨文件找重复)│ ★更高★(跨文件共享字典) │
  │ 损坏影响        │ 只坏那一个成员         │ ★后面全废★(流式依赖)   │
  │ 追加成员        │ ✅ 支持 "a" 模式      │ ❌ 压缩后无法追加        │
  └────────────────┴──────────────────────┴────────────────────────┘

  ★ 压缩率的算例(1000 个内容相似的 1KB 日志文件):
    zip(各自压缩)    ≈ 1000 × 300B = 300 KB
    tar.gz(整体压缩)  ≈ 40 KB      ← 跨文件的重复内容被一起压掉了
    → "很多相似小文件"用 tar.gz 优势巨大;"要随机取用某个文件"用 zip

选择准则:
  给 Windows 用户 / 要随机访问 / 要追加        → zip
  Unix 部署包 / 很多相似小文件 / 要保留权限属主 → tar.gz
  单个大文件只要压缩(不需要打包)              → gzip / xz 直接压(见"流压缩"专题)

理解这两个模块的前提是分清归档压缩是两件事:归档把多个文件变成一个文件(解决结构问题),压缩把数据变小(解决体积问题)。zip 把两件事合在一起做——每个成员单独压缩后放进容器,所以它有「中央目录」、能随机定位、能追加成员、某个成员损坏不影响其他;tar 只做归档(文件首尾相接),压缩交给外挂的 gzip/bz2/xz,整个流被当成一个整体压缩,因此压缩率更高(能跨文件找重复),但不能随机访问、不能追加、中途损坏会导致后面全废。上面那个算例很有说服力:1000 个内容相似的 1KB 日志,zip 约 300KB 而 tar.gz 只有 40KB——很多相似小文件用 tar.gz,要随机取用某个成员用 zip

二、zipfile 的读写与常用 API

写入(三种方式):
  z.write(文件路径, arcname=归档内名字)     从磁盘文件添加
  z.writestr(名字, 数据)                    ★直接写内存中的数据★(不需要临时文件)
  with z.open(名字, "w") as f: f.write(...)  流式写(3.6+,适合大文件)

  ★ arcname 的重要性:不指定时归档里会带上完整路径
    z.write("/home/u/data/a.csv")  → 归档内是 "home/u/data/a.csv"(泄漏目录结构)
    z.write("/home/u/data/a.csv", arcname="a.csv")  → 干净 ✓

压缩参数:
  compression=zipfile.ZIP_STORED    ★默认★,不压缩
              ZIP_DEFLATED          常用(需要 zlib,几乎总是可用)
              ZIP_BZIP2 / ZIP_LZMA  压缩率更高、更慢
  compresslevel=0~9                 3.7+,DEFLATED 默认 6

读取:
  z.namelist()          → ['a.csv', 'sub/b.txt'](★目录成员以 / 结尾★)
  z.infolist()          → [ZipInfo, ...](含 file_size / compress_size / date_time)
  z.read("a.csv")       → bytes(★整个读进内存★)
  z.open("a.csv")       → 文件对象(★流式,大文件用这个★)
  z.extract(名字, path) / z.extractall(path)   解压到磁盘
  z.testzip()           → 校验 CRC,返回第一个损坏成员的名字(None 表示都 OK)

文本成员的正确读法(★易错★):
  with z.open("a.csv") as f:          # ← f 是★二进制★流
      text = io.TextIOWrapper(f, encoding="utf-8", newline="")
      for row in csv.reader(text): ...
  ✗ 直接 csv.reader(f) 会报错(csv 要文本流,拿到的是 bytes)

其他实用能力:
  zipfile.is_zipfile(p)          判断是不是 zip
  z.setpassword(b"pw")           读加密 zip(★只支持解密,不支持创建加密 zip★;
                                  且标准 ZipCrypto 极不安全,AES 加密的 zip 标准库读不了)
  z.comment = b"..."             归档注释
  ZipFile(p, "a")                追加成员(★注意:同名成员会重复存在,读取时取最后一个★)

zipfile 的写入有三种姿势:write() 从磁盘添加、writestr() 直接写内存数据(生成报表打包时不必落临时文件)、z.open(name, "w") 流式写大文件。这里有个经常被忽略的细节:不指定 arcname 时,归档里会带上完整的原始路径——z.write("/home/u/data/a.csv") 会在 zip 里创建 home/u/data/a.csv 这样的层级,既难看又泄漏了服务器目录结构。读取时最容易踩的是编码问题z.open() 返回的是二进制流,直接喂给 csv.reader 会报错,正确做法是套一层 io.TextIOWrapper(f, encoding=...)。另外两个冷知识:标准库只能读加密 zip、不能创建(而且 ZipCrypto 极不安全,AES 加密的 zip 标准库根本读不了,需要 pyzipper);用 "a" 模式追加同名成员时旧成员不会被删除,两份都在文件里,读取时取最后一个——这会让 zip 悄悄变大。

三、tarfile 的模式字符串与元信息

tarfile.open(name, mode) 的模式字符串(★比 zipfile 复杂★):
  "r"    自动识别压缩(等价 "r:*")      ← 读的时候用这个最省心
  "r:"   ★不压缩★的 tar
  "r:gz" / "r:bz2" / "r:xz"             指定压缩
  "w:gz" 写 + gzip
  "a:"   追加(★只能追加到未压缩的 tar★)
  "r|gz" ★流模式★(管道/网络流,不可 seek,只能顺序读一遍)

  ★ "r:gz" 和 "r|gz" 的区别:
    冒号 = 可随机 seek(需要真实文件)
    竖线 = 纯流式(可以直接处理 sys.stdin.buffer 或网络响应,内存占用恒定)

tar 保留的元信息(比 zip 完整得多,这既是优点也是风险):
  TarInfo.name / size / mtime
  .mode         权限位(★包括 setuid/setgid★)
  .uid / .gid / .uname / .gname   属主
  .type         REGTYPE(普通文件) / DIRTYPE / SYMTYPE(软链接) / LNKTYPE(硬链接)
                / CHRTYPE、BLKTYPE(设备文件) / FIFOTYPE
  .linkname     链接指向的目标

  ★ 正是"能存软链接和设备文件"让 tar 的解压比 zip 更危险(见下一节)

添加时定制元信息(filter 参数):
  def scrub(ti: tarfile.TarInfo):
      ti.uid = ti.gid = 0
      ti.uname = ti.gname = "root"      # ★不泄漏打包机器的用户名★
      ti.mtime = 0                       # 可复现构建(每次打包字节一致)
      if ti.name.endswith(".log"):
          return None                    # ★返回 None=排除这个成员★
      return ti
  with tarfile.open("out.tar.gz", "w:gz") as t:
      t.add("app/", filter=scrub)

读取大归档的正确姿势:
  for member in t:                       # ★迭代 TarFile 是流式的,内存友好★
      if member.isfile() and member.name.endswith(".json"):
          data = t.extractfile(member).read()
  ✗ t.getmembers() 会★把整个成员表读进内存★(几十万个成员时很占内存)

tarfile 的模式字符串比 zipfile 复杂,但规律清晰:冒号 "r:gz" 表示可随机 seek(需要真实文件),竖线 "r|gz" 表示纯流模式——后者能直接处理管道、sys.stdin.buffer 或网络响应流,内存占用恒定,是「边下载边解压」的关键。读取时直接用 "r"(等价 "r:*")让它自动识别压缩方式最省心。tar 相比 zip 的最大特点是保留完整的 Unix 元信息:权限位(包括 setuid/setgid)、属主 uid/gid、以及成员类型(普通文件、目录、软链接、硬链接、设备文件、FIFO)——这既是它做部署包的优势,也正是它解压时更危险的原因。打包时推荐用 add(..., filter=函数) 清洗元信息:把 uid/gid 归零(避免泄漏打包机器的用户名)、把 mtime 固定(实现可复现构建)、返回 None 直接排除某些成员。最后记住读大归档要迭代 TarFile 对象而不是 getmembers()(后者会把整个成员表读进内存)。

四、解压的安全风险:ZipSlip、软链接与 zip 炸弹

风险 1:路径穿越(ZipSlip / TarSlip)★最常见★
  归档成员名可以是任意字符串:
    "../../../etc/cron.d/backdoor"
    "/etc/passwd"                        (绝对路径)
    "..\\..\\Windows\\System32\\x.dll"   (Windows 分隔符)
  extractall("uploads/") 时会照着这个名字写文件 → ★写到目标目录之外★
  真实危害:覆盖 crontab、覆盖 ~/.ssh/authorized_keys、覆盖 Web 应用的 .py 文件 → RCE

  ★ 注意 Python 的一个坑:os.path.join(base, "/etc/passwd") == "/etc/passwd"
    (join 遇到绝对路径会★丢弃前面所有部分★)→ 手写拼接极易出错

风险 2:软链接攻击(★tar 独有,比路径穿越更隐蔽★)
  归档里放两个成员:
    ① 成员 "link" 是一个软链接,指向 /etc
    ② 成员 "link/passwd" 是一个普通文件
  解压时先创建软链接,再往 "link/passwd" 写 → ★实际写到了 /etc/passwd★
  ★ 只校验成员名不含 ".." 是拦不住这种攻击的(两个名字都很"干净")

风险 3:zip 炸弹(解压炸弹)
  42.zip:42 KB → 解压出 4.5 PB(层层嵌套的高度重复数据)
  单层也能做到:1 MB 的全零文件压缩后只有几 KB,重复 10000 个成员
  ★ 危害:磁盘写满、内存耗尽、服务不可用
  ✓ 防御:解压前检查 sum(info.file_size for info in z.infolist())
          并限制成员数量;解压时也可以按已写字节数中途中断

风险 4:其他
  - 设备文件 / FIFO(tar 可以创建 /dev/xxx)
  - setuid 位(解压出一个 setuid root 的可执行文件)
  - 超长文件名 / 特殊字符(NUL、控制字符)导致的下游解析问题
  - 同名成员覆盖(先解压 a.txt,再解压另一个 a.txt)

★ Python 3.12+ 的官方答案:tarfile 的 filter 参数(PEP 706)
  t.extractall(path, filter="data")     ← ★推荐★,最严格:
      拒绝 绝对路径、..、指向归档外的软链接、设备文件、FIFO
      清除 setuid/setgid 位、属主信息
  filter="tar"      仅去掉绝对路径和 ..,保留 Unix 元信息(用于可信来源)
  filter="fully_trusted"   旧行为(完全不检查)
  ★ 3.12/3.13 不指定 filter 会有 DeprecationWarning;★3.14 起默认就是 "data"★

★ zipfile 没有 filter,必须自己写校验:
  def is_safe(dest: Path, name: str) -> bool:
      target = (dest / name).resolve()
      return target.is_relative_to(dest)          # ★Python 3.9+★
      # 3.8 及以下:os.path.commonpath([dest, target]) == str(dest)

这是这道题的核心。风险一是路径穿越:归档成员名可以是 ../../../etc/cron.d/x 或绝对路径,extractall() 会照着写、把文件落到目标目录之外——覆盖 crontab、authorized_keys 或 Web 应用的 .py 文件都能直接变成远程代码执行;Python 里还有个额外的坑是 os.path.join(base, "/etc/passwd") 会丢弃 base 直接返回绝对路径,手写拼接极易出错。风险二是软链接攻击(tar 独有且更隐蔽):归档里先放一个指向 /etc 的软链接 link,再放一个成员 link/passwd——两个名字都不含 ..、看起来完全干净,解压后却写进了 /etc/passwd,所以只校验成员名是拦不住的风险三是 zip 炸弹:42KB 解压出 4.5PB,防御手段是解压前累加 file_size 并限制成员数量。官方答案是 Python 3.12 引入的 tarfile 过滤器(PEP 706)extractall(path, filter="data") 会拒绝绝对路径、..、指向归档外的软链接和设备文件,并清除 setuid 位;3.14 起它成为默认。而 zipfile 至今没有过滤器,必须自己用 (dest / name).resolve().is_relative_to(dest) 逐个成员校验。

五、安全解压的完整实现

一个可以直接抄的安全解压函数(zip 版):

  from pathlib import Path
  import zipfile

  MAX_TOTAL = 1 * 1024**3        # 解压后总大小上限 1 GB
  MAX_FILES = 10_000             # 成员数量上限
  MAX_RATIO = 100                # ★压缩比上限(防炸弹)★

  def safe_unzip(archive: Path, dest: Path) -> None:
      dest = dest.resolve()
      dest.mkdir(parents=True, exist_ok=True)
      with zipfile.ZipFile(archive) as z:
          infos = z.infolist()
          # ① 数量
          if len(infos) > MAX_FILES:
              raise ValueError(f"成员过多: {len(infos)}")
          # ② 总大小 + 压缩比
          total = sum(i.file_size for i in infos)
          packed = sum(i.compress_size for i in infos) or 1
          if total > MAX_TOTAL or total / packed > MAX_RATIO:
              raise ValueError(f"疑似解压炸弹: {total} bytes, ratio={total/packed:.0f}")
          # ③ 逐个成员校验路径
          for i in infos:
              name = i.filename
              if name.startswith("/") or ".." in Path(name).parts:
                  raise ValueError(f"非法成员名: {name}")
              target = (dest / name).resolve()
              if not target.is_relative_to(dest):        # ★最终防线★
                  raise ValueError(f"路径穿越: {name}")
          z.extractall(dest)

  ★ 三层校验缺一不可:
    startswith("/")  挡绝对路径
    ".." in parts    挡显式穿越(比字符串 in ".." 更准,避免误伤 "a..b.txt")
    is_relative_to   ★最终防线★(处理符号链接、Windows 分隔符、编码技巧等绕过)

tar 版(3.12+ 直接用 filter):
  with tarfile.open(archive) as t:
      t.extractall(dest, filter="data")     # ★一行搞定,优先用它★

  兼容老版本:
  def safe_untar(archive, dest):
      dest = Path(dest).resolve()
      with tarfile.open(archive) as t:
          for m in t.getmembers():
              if not (m.isfile() or m.isdir()):     # ★拒绝软链接/设备文件/FIFO★
                  raise ValueError(f"不允许的成员类型: {m.name} ({m.type})")
              target = (dest / m.name).resolve()
              if not target.is_relative_to(dest):
                  raise ValueError(f"路径穿越: {m.name}")
              m.mode = m.mode & 0o777 & ~0o7000      # ★清除 setuid/setgid/sticky★
          t.extractall(dest)

更稳妥的工程做法(纵深防御):
  ① 解压到★一次性的临时目录★,校验通过后再移动到正式位置
  ② 用独立的低权限用户 / 容器 / 沙箱执行解压
  ③ 磁盘配额(quota)或 ulimit -f 限制写入量
  ④ 只接受白名单内的扩展名,解压后再做一次类型校验(魔数)
  ⑤ 记录日志:谁上传的、成员列表、大小——出事能追溯

安全解压要做三层路径校验,缺一不可:startswith("/") 挡绝对路径、".." in Path(name).parts 挡显式穿越(parts 而不是字符串 in,避免误伤 a..b.txt 这类合法文件名)、最后 (dest / name).resolve().is_relative_to(dest) 作为最终防线(它能处理符号链接、Windows 分隔符和各种编码绕过技巧)。防炸弹则要同时看成员数量、解压后总大小、压缩比三个指标。tar 在 3.12+ 直接 filter="data" 一行搞定;老版本要显式拒绝非普通文件/目录的成员类型(软链接、设备文件、FIFO)并清除 setuid/setgid 位。真正的生产实践还应该加上纵深防御:先解压到一次性临时目录、校验通过再移动,用低权限用户或容器执行解压,配上磁盘配额,最后再对解压出的文件做一次类型校验。

六、性能、内存与选型

压缩算法对比(同一份 100 MB 日志的量级感受):
  ┌──────────┬──────────┬──────────┬──────────┬────────────────┐
  │ 算法      │ 压缩后    │ 压缩耗时  │ 解压耗时  │ 适合             │
  ├──────────┼──────────┼──────────┼──────────┼────────────────┤
  │ 不压缩    │ 100 MB   │ —        │ —        │ 已压缩的数据(jpg)│
  │ gzip/-6  │ ~12 MB   │ ~3 s     │ ~0.7 s   │ ★通用默认★      │
  │ gzip/-1  │ ~16 MB   │ ~1 s     │ ~0.7 s   │ 实时/带宽换 CPU  │
  │ bz2      │ ~9 MB    │ ~12 s    │ ~4 s     │ 极少用(慢)     │
  │ xz/lzma  │ ~7 MB    │ ~40 s    │ ~1.5 s   │ ★发行包★(压一次解多次)│
  │ zstd     │ ~11 MB   │ ~1 s     │ ~0.3 s   │ 现代首选(3.14 进标准库)│
  └──────────┴──────────┴──────────┴──────────┴────────────────┘
  ★ 决策:压一次解多次 → xz;频繁压缩 → gzip/zstd;已压缩数据 → 别再压(会变大)

内存注意事项:
  ✗ z.read("big.bin")            ★整个成员读进内存★
  ✓ with z.open("big.bin") as f: shutil.copyfileobj(f, out)   流式,恒定内存
  ✗ t.getmembers()               成员表全进内存(几十万成员时很重)
  ✓ for m in t: ...              流式迭代
  ✓ tarfile.open(fileobj=响应流, mode="r|gz")   ★边下载边解压★,不落地

不落地的常见组合:
  内存里造 zip 返回给前端:
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
        z.writestr("report.csv", csv_text)
    return buf.getvalue()
  直接读远程 tar.gz:
    with tarfile.open(fileobj=resp.raw, mode="r|gz") as t:
        for m in t: ...

其他选型:
  只压单个文件、不需要打包        → gzip / lzma 模块(流压缩,见对应专题)
  要跨平台、给用户下载            → zip(Windows 双击就能开)
  Python 项目分发                 → wheel(本质就是 zip)
  超大数据 / 列式分析              → 不要用归档,用 parquet 等格式
  需要加密                        → 不要用 zip 的 ZipCrypto(已破解);
                                    用 age/gpg 加密,或 pyzipper(AES)

选型时先看压缩算法gzip 是通用默认(快、够用),xz/lzma 压缩率最高但压缩极慢——适合「压一次、解很多次」的发行包;bz2 基本被淘汰;zstd 是现代首选(Python 3.14 起进标准库)。有个常识要记住:已经压缩过的数据(jpg、mp4、zip)再压不但没用还会变大。内存方面的铁律是能流式就别整读z.read() 会把整个成员读进内存,应该用 z.open()shutil.copyfileobjt.getmembers() 会把成员表全读进来,应该直接迭代 TarFile。两个很实用的「不落地」组合:用 io.BytesIO 在内存里造 zip 直接返回给前端(不写临时文件),以及 tarfile.open(fileobj=响应流, mode="r|gz") 边下载边解压

记忆钩子:「先分清两件事:★归档=把多文件打成一个(tar),压缩=把数据变小(gzip)★,zip 两件事一起做、tar 只打包所以要外挂 gzip 才有 .tar.gz。由此推出全部差异:zip 有中央目录→可随机访问/可追加/单个成员损坏不影响其他,但压缩率低;tar.gz 整体压缩→压缩率高(1000 个相似小文件 300KB vs 40KB)但必须顺序解压、不能追加、中途损坏后面全废。zip 的坑:★不传 compression 默认 ZIP_STORED=不压缩★、不传 arcname 会把完整路径写进归档、z.open() 返回的是二进制流(读文本要套 TextIOWrapper)、标准库只能读加密 zip 不能创建。tar 的特点是保留完整 Unix 元信息(权限含 setuid、属主、★软链接/设备文件★),模式串里冒号可 seek、竖线是纯流式(能边下载边解压)。★安全是本题重点:绝不要对不可信归档直接 extractall()★——①路径穿越(成员名写成 ../../etc/cron.d/x 或绝对路径,注意 os.path.join 遇绝对路径会丢弃前缀)②★tar 特有的软链接攻击★(先建指向 /etc 的软链接、再往链接内写文件,两个成员名都很干净,只查 .. 拦不住)③zip 炸弹(42KB 解压出 4.5PB,要查成员数+总大小+压缩比)。官方答案:★Python 3.12+ tarfile.extractall(filter=‘data’)(3.14 起为默认)★;zipfile 至今没有过滤器,必须自己三层校验:startswith(’/’)、’..’ in Path(name).parts、最终防线 (dest/name).resolve().is_relative_to(dest)。」

七、常见误区与追问

  • 误区:ZipFile(p, "w") 写出来的就是压缩过的 zip。 不是——不传 compression 参数时默认是 zipfile.ZIP_STORED,即「只打包不压缩」,生成的 zip 体积和原始文件总和几乎一样(只多了元数据)。这是「为什么我的 zip 没变小」最常见的原因。要压缩必须显式写 compression=zipfile.ZIP_DEFLATED(还可以用 3.7+ 的 compresslevel=0~9 调级别)。顺带记住反过来的情况:对已经压缩过的数据(jpg、mp4、已有的 zip)再做 DEFLATE 不但省不了空间,还会因为额外的元数据略微变大,这类文件用 ZIP_STORED 才是对的。
  • 误区:解压前检查成员名里有没有 .. 就安全了。 这道防线能挡住最初级的路径穿越,但至少有三种绕过方式:① 绝对路径/etc/passwd),成员名里根本没有 ..;② Windows 路径分隔符..\\..\\x)在某些处理下不会被按 .. 识别;③ 软链接攻击(tar 特有)——归档里先放一个指向 /etc 的软链接 link,再放一个成员 link/passwd,两个名字都完全「干净」,解压后却写进了 /etc。所以正确的做法是以「最终解析路径」为准(dest / name).resolve().is_relative_to(dest)(Python 3.9+),并且对 tar 额外拒绝非普通文件/目录的成员类型。3.12+ 的 tarfile 直接用 filter="data" 是最省心的答案。
  • 误区:.tar.gz 里可以像 zip 一样直接读某一个文件。 不能高效做到。.tar.gz先打包成 tar 流、再把整个流压缩,因此没有「中央目录」这种索引结构——想读中间某个成员,必须从头解压到那个位置。归档里有一万个文件、只想取第 9999 个时,等于把整个包解了一遍。zipfile 则相反:每个成员独立压缩并在文件尾部维护中央目录,可以直接 seek 到该成员的偏移量读取。所以「归档要被随机访问」(比如做成一个可查询的数据包)必须用 zip;「一次性整体解压、且文件多而相似」用 tar.gz 压缩率更高。这也解释了为什么 Python 的 wheel 包用 zip 格式——安装器需要按需读取里面的 METADATA
  • 误区:extractall() 的第一个参数指定了目录,文件就一定落在这个目录里。 extractall(path) 只是把 path 作为基准目录去拼接成员名,成员名是 ../../x/etc/x 时文件就会落到外面——这就是 ZipSlip / 路径穿越漏洞,在归档来自用户上传、第三方下载、CI 缓存等不可信来源时是高危 RCE(覆盖 crontab、~/.ssh/authorized_keys、Web 应用的 .py 文件都能直接拿到执行权)。Python 里还有个加剧问题的细节:os.path.join(base, "/etc/passwd") 会丢弃 base 直接返回 /etc/passwd,手写路径拼接的校验代码极易在这里出错。结论就是那句话:永远不要对不可信归档直接调用 extractall()
  • 误区:zip 的密码保护可以用来保护敏感数据。 两个问题。① 标准库只能读加密 zip、不能创建setpassword() 仅用于解密);② 传统 zip 加密用的 ZipCrypto 算法早已被攻破——已知明文攻击可以在几分钟内恢复密钥,甚至只要知道归档里某个文件的部分内容就够了。而更安全的 AES 加密 zip(WinZip AES)标准库根本读不了,需要 pyzipper 这类第三方库。所以「用 zip 密码保护数据」在安全上基本等于没有保护。真正需要保密就用成熟的加密工具(agegpg)或先用 cryptography 库加密再打包,并且注意:zip 的文件名列表始终是明文的(即使内容加密),元数据本身也可能泄漏信息。
  • 追问:tarfilefilter 参数(PEP 706)具体做了什么,三个预设有什么区别? 它是 Python 3.12 引入的解压过滤机制,作用是在写入每个成员前对 TarInfo 做检查和清洗。三个预设从松到紧:"fully_trusted" 是 3.11 及以前的旧行为——完全不检查,只应用于自己生成的、完全可信的归档;"tar" 会拒绝绝对路径和指向归档外的 .. 路径,但保留 Unix 元信息(权限、属主、软链接),适合 Unix 部署包这类「来源可信但要防手滑」的场景;"data" 最严格,除了路径检查外还会拒绝软链接指向归档外的目标、拒绝设备文件和 FIFO、清除 setuid/setgid 位、丢弃属主信息,适合处理任何不可信来源。迁移节奏是:3.12/3.13 不指定 filter 会发 DeprecationWarning3.14 起默认就是 "data"。还可以传入自定义函数(接收 TarInfo 和目标路径,返回修改后的 TarInfoNone 表示跳过)。注意 zipfile 没有对应机制,仍需自己校验。
  • 追问:怎么防御 zip 炸弹?只看压缩包大小够吗? 不够——zip 炸弹的定义就是「压缩包很小、解压后极大」(经典的 42.zip 是 42KB 解压出 4.5PB),只看输入文件大小完全挡不住。要看三个指标:① 解压后总大小 sum(i.file_size for i in z.infolist())(注意这个字段来自归档头部,理论上可以被伪造,所以它是第一道筛而不是最终保证);② 压缩比 总解压大小 / 总压缩大小,正常文本大约 3~10 倍,超过 100 倍就非常可疑;③ 成员数量(有的炸弹是靠几百万个小文件耗尽 inode 和内存)。更可靠的做法是在解压过程中实时计数:用 z.open() 流式读取并累计已写字节数,超过阈值立刻中断删除。另外还要防嵌套炸弹(zip 里套 zip),所以「解压后的文件如果还是归档,不要自动递归解压」。工程上再叠加磁盘配额、ulimit -f、容器资源限制作为兜底。
  • 追问:什么时候该用 zip、什么时候该用 tar.gz? 按三个维度决策。① 访问方式:需要随机读取单个成员(可查询的数据包、Python wheel、Office 文档格式)必须用 zip,因为它有中央目录;一次性整体解压用 tar.gz。② 内容特征大量相似的小文件(日志、源码)用 tar.gz 压缩率碾压 zip(整体压缩能跨文件找重复,实测可以差数倍);文件少而大、或本身已压缩则差别不大。③ 平台与元信息:给 Windows 用户下载用 zip(双击即开);Unix 部署包用 tar.gz,因为它完整保留权限、属主和软链接(zip 对这些支持有限)。另外还有两个实际考虑:需要追加成员只能用 zip(压缩后的 tar 无法追加);对损坏容忍度要求高时 zip 更好(单个成员损坏不影响其他,而 tar.gz 中途损坏会导致后面全部无法解压)。

八、加强记忆

先分清两件事:归档(把多文件打成一个)和压缩(把数据变小)。zip 两件事一起做(每个成员独立压缩),tar 只做归档、压缩靠外挂 gzip/bz2/xz,所以才有 .tar.gz 这种双扩展名。全部差异都由此推出:zip 有中央目录→可随机访问单个成员、可追加、单个成员损坏不影响其他,但压缩率低;tar.gz 整体压缩→压缩率高得多(1000 个相似小文件:zip 约 300KB vs tar.gz 约 40KB)、完整保留 Unix 元信息,但必须顺序解压、不能追加、中途损坏则后面全废zipfile 的坑:不传 compression默认 ZIP_STORED 即不压缩;不传 arcname 会把完整原始路径写进归档;z.open() 返回二进制流(读文本要套 io.TextIOWrapper);标准库只能读加密 zip 不能创建,且 ZipCrypto 早已被攻破。tarfile 的特点:模式串里冒号可 seek、竖线是纯流式(能边下载边解压);add(..., filter=函数) 可清洗属主/mtime 并排除成员;读大归档要迭代 TarFile 而不是 getmembers()安全是本题重点——绝不要对不可信归档直接 extractall():① 路径穿越(成员名写成 ../../etc/cron.d/x 或绝对路径;注意 os.path.join 遇绝对路径会丢弃前缀);② tar 特有的软链接攻击(先建指向 /etc 的软链接、再往链接内写文件,两个成员名都很干净,只查 .. 拦不住);③ zip 炸弹(42KB 解压出 4.5PB,要同时查成员数、解压后总大小、压缩比)。官方答案是 Python 3.12 的 tarfile.extractall(filter="data")(3.14 起成为默认),三档预设分别是 fully_trusted(旧行为)/tar(挡路径)/data(最严,还拒绝软链接外指、设备文件并清 setuid);而 zipfile 至今没有过滤器,必须自己做三层校验:startswith("/")".." in Path(name).parts、以及最终防线 (dest / name).resolve().is_relative_to(dest),再配合「先解压到临时目录、校验通过再移动」的纵深防御。