Skip to content

zipfile 压缩文件 ​

当程序需要把课程资料打包、交付或解压时,如何确认归档结构正确、路径不越界,并控制展开后的资源规模?zipfile 模块用于创建、读取和解压 ZIP 文件。ZIP 可以把多个文件和目录打包成一个文件,并根据配置压缩其中的数据。

本章完成一条完整流程:

  1. 把课程资料写入 ZIP。
  2. 查看并校验归档成员。
  3. 不解压直接读取一个文件。
  4. 检查路径和展开大小。
  5. 解压到明确的目标目录。

zipfile 只处理 ZIP 格式,不负责 RAR 或 7z 文件。

ZIP 文件的安全处理流程

归档创建与写入 ​

zipfile.ZipFile 使用模式控制打开行为:

模式作用注意事项
r读取已有 ZIP文件不存在或格式错误时会报错
w创建 ZIP已有文件会被覆盖
x创建新 ZIP已有文件会抛出 FileExistsError
a追加成员可能产生同名成员

创建新压缩包时,如果不允许覆盖已有文件,优先使用 x 模式。

ZipFile 应配合 with 使用,离开代码块时会自动写完目录信息并关闭文件:

python
from pathlib import Path
import zipfile

archive_path = Path("course.zip")

with zipfile.ZipFile(archive_path, "x") as zip_file:
    pass

压缩方式:

ZipFile 默认使用 ZIP_STORED,只打包文件,不压缩数据。需要常见的 ZIP 压缩时,应明确使用 ZIP_DEFLATED:

python
with zipfile.ZipFile(
    archive_path,
    "x",
    compression=zipfile.ZIP_DEFLATED,
    compresslevel=6,
) as zip_file:
    pass

其中:

  • ZIP_STORED:只存储,不压缩。
  • ZIP_DEFLATED:使用常见的 Deflate 算法压缩。
  • compresslevel=6:在压缩速度和文件大小之间取一个常用平衡值。

图片、视频等文件本身可能已经压缩,再放入 ZIP 后不一定会明显变小。很小的文件还可能因为归档信息而略微变大。

zipfile 压缩模式选型与不同文件类型压缩比真相

写入文件与内部路径:

ZipFile.write() 把磁盘文件写入 ZIP:

python
zip_file.write(source_path)

如果不指定归档内部名称,源路径的一部分可能被带入 ZIP。推荐通过 arcname 主动控制:

python
zip_file.write(
    source_path,
    arcname="course_materials/readme.txt",
)

批量写入目录时,可以使用 Path.rglob():

python
from pathlib import Path, PurePosixPath
import zipfile

source_dir = Path("source_files")
archive_path = Path("course.zip")

with zipfile.ZipFile(
    archive_path,
    "x",
    compression=zipfile.ZIP_DEFLATED,
    compresslevel=6,
) as zip_file:
    for file_path in sorted(source_dir.rglob("*")):
        if not file_path.is_file():
            continue

        relative_path = file_path.relative_to(source_dir)
        archive_name = PurePosixPath("course_materials", *relative_path.parts)
        zip_file.write(file_path, archive_name.as_posix())

ZIP 内部路径统一使用 /。PurePosixPath 只负责生成归档名称,不会访问文件系统。

arcname 归档内部路径重映射与跨平台斜杠规范

归档读取与完整性检查 ​

以读取模式打开 ZIP 后,可以通过 infolist() 得到所有 ZipInfo 对象:

python
import zipfile

with zipfile.ZipFile("course.zip", "r") as zip_file:
    for info in zip_file.infolist():
        print(info.filename)
        print(info.is_dir())
        print(info.file_size)
        print(info.compress_size)
属性或方法含义
filenameZIP 中保存的成员名称
is_dir()成员是否为目录
file_size原始数据大小
compress_size压缩后数据大小

namelist() 只返回成员名称列表;需要大小等详细信息时,直接使用 infolist() 更方便。

不解压读取成员:

read() 可以直接读取 ZIP 中某个成员,返回字节数据:

python
import zipfile

with zipfile.ZipFile("course.zip", "r") as zip_file:
    content = zip_file.read("course_materials/readme.txt")
    print(content.decode("utf-8"))

只需要查看配置或少量文本时,不必先把整个 ZIP 解压到磁盘。

ZipInfo 成员属性账本与内存免解压直读机制

归档结构与成员校验:

ZIP 结构判断:

zipfile.is_zipfile() 可以判断文件是否具有有效的 ZIP 结构:

python
import zipfile

if zipfile.is_zipfile("course.zip"):
    print("这是一个 ZIP 文件")

它只能判断 ZIP 结构,不能证明文件内容安全可信。

成员完整性检查:

testzip() 会读取归档成员并检查 CRC:

python
import zipfile

with zipfile.ZipFile("course.zip", "r") as zip_file:
    bad_member = zip_file.testzip()

    if bad_member is None:
        print("成员检查通过")
    else:
        print(f"损坏成员:{bad_member}")

归档较大时,完整检查需要读取全部成员,可能花费较长时间。

安全解压与边界检查 ​

extract() 解压一个成员,extractall() 解压多个成员:

python
from pathlib import Path
import zipfile

extract_dir = Path("extracted")

with zipfile.ZipFile("course.zip", "r") as zip_file:
    zip_file.extract("course_materials/readme.txt", path=extract_dir)
    zip_file.extractall(path=extract_dir)

目标目录不存在时会自动创建。目标位置已经有同名文件时,解压可能覆盖原有内容,因此应使用专门的空目录接收解压结果。

当前 CPython 3.14 会在解压时规范化明显的盘符、绝对路径和 .. 片段,但来源不明的 ZIP 仍然不应该直接交给 extractall()。解压前还需要检查成员名称、展开大小和目标范围。

解压前边界检查:

下面的函数完成三个基础检查:

  • 拒绝绝对路径、上级目录和疑似 Windows 盘符。
  • 确认计算后的目标仍位于解压目录中。
  • 限制所有成员的总展开大小。
python
from pathlib import Path, PurePosixPath


MAX_TOTAL_SIZE = 10 * 1024 * 1024


def validate_members(zip_file, extract_dir):
    extract_root = extract_dir.resolve()
    total_size = 0

    for info in zip_file.infolist():
        normalized_name = info.filename.replace("\\", "/")
        member_path = PurePosixPath(normalized_name)

        if member_path.is_absolute() or ".." in member_path.parts:
            raise ValueError(f"不安全的成员路径:{info.filename}")
        if member_path.parts and ":" in member_path.parts[0]:
            raise ValueError(f"不安全的成员路径:{info.filename}")

        destination = (extract_root / Path(*member_path.parts)).resolve()
        if not destination.is_relative_to(extract_root):
            raise ValueError(f"成员超出解压目录:{info.filename}")

        total_size += info.file_size
        if total_size > MAX_TOTAL_SIZE:
            raise ValueError("ZIP 展开后的总大小超过限制")

    return total_size

这个检查适合课程案例,但不能代替真实系统的完整安全策略。处理外部 ZIP 时,还要考虑文件数量、单个文件大小、磁盘空间、覆盖策略和资源限制。

不可信 ZIP 解压前的三重安全拦截门禁

文件名与内容编码 ​

Python 创建包含中文名称的 ZIP 时,通常会自动使用 ZIP 的 UTF-8 标记:

python
zip_file.write(source_path, "课程资料/说明.txt")

现代工具一般可以正确读取。旧软件生成的 ZIP 可能没有正确标记文件名编码,读取时才可能需要针对来源设置 metadata_encoding。不要在没有确认压缩包来源和编码的情况下随意猜测。

文件内容编码与文件名编码是两件事。通过 read() 读取文本内容后,仍然要使用正确编码解码,例如 decode("utf-8")。

案例:创建并安全解压课程资料 ​

需求: 将课程资料打包为可交付的 ZIP,检查归档结构和成员完整性,读取其中的文本,并在确认路径与展开大小后解压到临时目录。

功能分析: 使用 ZIP_DEFLATED 和 arcname 创建稳定的内部路径,通过 is_zipfile()、infolist()、testzip() 完成校验,再用成员路径与总展开大小检查保护 extractall() 的目标范围。

案例提供下面的源文件:

text
01-safe-archive/
├── main.py
└── source_files/
    ├── readme.txt
    ├── data/
    │   └── scores.csv
    └── notes/
        └── lesson.md

按目录树创建三个源文件,程序会把它们写入 ZIP:

text
课程压缩案例源文件。
text
name,score
张三,90
李四,85
text
# ZIP 文件练习

使用 zipfile 创建和安全解压课程资料。

程序在临时工作区中创建 ZIP、检查成员、读取文本并安全解压:

python
from pathlib import Path, PurePosixPath
from tempfile import TemporaryDirectory
import zipfile


MAX_TOTAL_SIZE = 10 * 1024 * 1024


def create_archive(source_dir, archive_path):
    with zipfile.ZipFile(
        archive_path,
        "x",
        compression=zipfile.ZIP_DEFLATED,
        compresslevel=6,
    ) as zip_file:
        for file_path in sorted(source_dir.rglob("*")):
            if not file_path.is_file():
                continue

            relative_path = file_path.relative_to(source_dir)
            archive_name = PurePosixPath("course_materials", *relative_path.parts)
            zip_file.write(file_path, archive_name.as_posix())


def validate_members(zip_file, extract_dir):
    extract_root = extract_dir.resolve()
    total_size = 0

    for info in zip_file.infolist():
        normalized_name = info.filename.replace("\\", "/")
        member_path = PurePosixPath(normalized_name)

        if member_path.is_absolute() or ".." in member_path.parts:
            raise ValueError(f"不安全的成员路径:{info.filename}")
        if member_path.parts and ":" in member_path.parts[0]:
            raise ValueError(f"不安全的成员路径:{info.filename}")

        destination = (extract_root / Path(*member_path.parts)).resolve()
        if not destination.is_relative_to(extract_root):
            raise ValueError(f"成员超出解压目录:{info.filename}")

        total_size += info.file_size
        if total_size > MAX_TOTAL_SIZE:
            raise ValueError("ZIP 展开后的总大小超过限制")

    return total_size


def main():
    script_dir = Path(__file__).resolve().parent
    source_dir = script_dir / "source_files"

    with TemporaryDirectory() as temp_dir:
        workspace = Path(temp_dir)
        archive_path = workspace / "course.zip"
        extract_dir = workspace / "extracted"

        create_archive(source_dir, archive_path)
        print(f"ZIP 格式有效:{zipfile.is_zipfile(archive_path)}")

        with zipfile.ZipFile(archive_path, "r") as zip_file:
            print("归档成员:")
            for info in zip_file.infolist():
                print(f"- {info.filename}")

            print(f"成员检查通过:{zip_file.testzip() is None}")

            content = zip_file.read("course_materials/readme.txt")
            print(f"读取内容:{content.decode('utf-8')}")

            validate_members(zip_file, extract_dir)
            zip_file.extractall(extract_dir)

        extracted_root = extract_dir / "course_materials"
        extracted_files = sorted(
            path.relative_to(extracted_root).as_posix()
            for path in extracted_root.rglob("*")
            if path.is_file()
        )

        print("解压文件:")
        for filename in extracted_files:
            print(f"- {filename}")


if __name__ == "__main__":
    main()

运行结果:

text
ZIP 格式有效:True
归档成员:
- course_materials/data/scores.csv
- course_materials/notes/lesson.md
- course_materials/readme.txt
成员检查通过:True
读取内容:课程压缩案例源文件。
解压文件:
- data/scores.csv
- notes/lesson.md
- readme.txt

案例生成的 ZIP 和解压目录都位于临时工作区,运行结束后会自动清理,不会污染课程目录。

小结 ​

  • ZipFile 的 r、w、x、a 模式分别用于读取、覆盖创建、安全创建和追加。
  • 默认的 ZIP_STORED 不压缩数据,常见 ZIP 压缩使用 ZIP_DEFLATED。
  • arcname 用于控制 ZIP 内部路径,避免写入不需要的上级目录。
  • infolist() 可以查看成员名称、类型和大小。
  • is_zipfile() 检查 ZIP 结构,testzip() 检查成员 CRC。
  • 解压前应检查成员路径、总展开大小和目标覆盖范围。
  • ZIP 文件名编码和文件内容编码是两个不同问题。

练习 ​

  1. 使用 ZIP_DEFLATED 创建一个新的 ZIP,并通过 arcname 控制内部路径。
  2. 使用 infolist() 输出每个成员的原始大小和压缩后大小。
  3. 使用 read() 直接读取 ZIP 中的一个 UTF-8 文本文件。
  4. 修改安全检查函数,增加文件数量限制。
  5. 在临时目录中解压案例 ZIP,并验证解压后的文件内容。