功能说明
服务端导出、备份和批量下载经常需要把目录打包成 Zip。直接遍历并写入文件名存在两个风险:绝对路径会泄露服务器目录结构,包含上级目录的相对路径在解压时可能写到目标目录之外。本例以源目录为根计算相对路径,发现路径等于两个点或以两个点加分隔符开头时立即返回错误,同时把分隔符统一转换为正斜杠,符合 Zip 规范。
文件元数据通过 FileInfoHeader 复制,保留修改时间和权限;压缩方法设为 Deflate。演示程序创建临时源目录,写入一个文本文件,打包到系统临时目录,再打开归档打印条目名,最后清理资源。
关键代码
WriteZip 先创建目标文件并延迟关闭,再创建 zip.Writer。WalkDir 的根被 Clean 处理,回调收到目录时跳过。filepath.Rel 计算相对路径,随后做前缀检查,避免两个点开头的结果混入归档。entry.Info 取得文件信息,zip.FileInfoHeader 生成头部,Name 改成斜杠形式,Method 设为 Deflate。CreateHeader 返回写入器,复制文件内容后显式关闭文件句柄,分别处理复制与关闭错误,避免在遍历中堆积描述符。
使用注意
压缩包内条目名必须使用正斜杠,解压端才能在各平台正确还原层级。该函数只防写入端路径穿越,解压端仍需再次校验,尤其是接收外部归档时。符号链接会被当作普通文件读取其目标内容,可能泄露根目录之外的数据,必要时应跳过非常规文件。大文件写入前应检查磁盘余量,打包过程不是原子的,失败会留下不完整归档。并发写同一个目标文件会互相覆盖。
验证方式
运行脚本,输出 archived: hello.txt,说明相对路径正确且文件已写入归档。解压该文件应得到内容 hello。可把源目录换成包含符号链接的目录,观察当前实现会读取链接目标;再添加跳过非常规文件的判断,确认链接不再进入归档。
代码示例
package main
import (
"archive/zip"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
func WriteZip(sourceDir, target string) error {
out, err := os.Create(target)
if err != nil {
return err
}
defer out.Close()
archive := zip.NewWriter(out)
defer archive.Close()
root := filepath.Clean(sourceDir)
return filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
relative, err := filepath.Rel(root, path)
if err != nil {
return err
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return fmt.Errorf("refusing path outside root: %s", relative)
}
info, err := entry.Info()
if err != nil {
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = filepath.ToSlash(relative)
header.Method = zip.Deflate
writer, err := archive.CreateHeader(header)
if err != nil {
return err
}
file, err := os.Open(path)
if err != nil {
return err
}
_, copyErr := io.Copy(writer, file)
closeErr := file.Close()
if copyErr != nil {
return copyErr
}
return closeErr
})
}
func main() {
source, err := os.MkdirTemp("", "zip-source-")
if err != nil {
panic(err)
}
defer os.RemoveAll(source)
if err := os.WriteFile(filepath.Join(source, "hello.txt"), []byte("hello"), 0o644); err != nil {
panic(err)
}
target := filepath.Join(os.TempDir(), "devexamples-demo.zip")
defer os.Remove(target)
if err := WriteZip(source, target); err != nil {
panic(err)
}
reader, err := zip.OpenReader(target)
if err != nil {
panic(err)
}
defer reader.Close()
for _, file := range reader.File {
fmt.Println("archived:", file.Name)
}
}