404 lines
12 KiB
Go
404 lines
12 KiB
Go
package compress
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"compress/gzip"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"io/fs"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
)
|
||
|
||
// 解压安全相关错误。
|
||
var (
|
||
// ErrPathTraversal zip 条目名逃逸目标目录(C5a Zip-Slip)。
|
||
ErrPathTraversal = errors.New("zip entry escapes destination directory")
|
||
// ErrSymlinkEntry zip 条目为符号链接,已拒绝(防经软链二次穿越)。
|
||
ErrSymlinkEntry = errors.New("zip entry is a symlink, rejected")
|
||
// ErrDecompressLimit 解压大小超过上限(C5b 解压炸弹)。
|
||
ErrDecompressLimit = errors.New("decompress size limit exceeded")
|
||
)
|
||
|
||
const (
|
||
// defaultDecompressLimit 单流/单条目默认解压上限(100MB),防 OOM / 磁盘耗尽(C5b)。
|
||
defaultDecompressLimit int64 = 100 * 1024 * 1024
|
||
// defaultDecompressTotalLimit Unzip 默认累计解压上限(1GB)。
|
||
defaultDecompressTotalLimit int64 = 1 * 1024 * 1024 * 1024
|
||
)
|
||
|
||
// DecompressOptions 解压安全选项。Zip-Slip 防护(前缀锚定 + 拒绝符号链接)始终启用,无需配置;
|
||
// 本选项仅控制解压大小上限以防解压炸弹(C5b)。
|
||
type DecompressOptions struct {
|
||
// MaxBytes 单流 / 单条目解压大小上限(字节)。0 = 默认 100MB,-1 = 不限制。
|
||
MaxBytes int64
|
||
// MaxTotalBytes Unzip 累计解压大小上限(字节)。0 = 默认 1GB,-1 = 不限制。
|
||
// 仅 Unzip 生效。
|
||
MaxTotalBytes int64
|
||
}
|
||
|
||
// resolveLimit 解析大小上限:n<0 不限,n==0 用 def,n>0 用 n。
|
||
func resolveLimit(n, def int64) int64 {
|
||
if n < 0 {
|
||
return -1
|
||
}
|
||
if n == 0 {
|
||
return def
|
||
}
|
||
return n
|
||
}
|
||
|
||
// minLimit 返回两个上限中较小者;-1 视为无限。
|
||
func minLimit(a, b int64) int64 {
|
||
if a < 0 {
|
||
return b
|
||
}
|
||
if b < 0 {
|
||
return a
|
||
}
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
// GzipCompress 压缩数据
|
||
func GzipCompress(data []byte) ([]byte, error) {
|
||
var buf bytes.Buffer
|
||
gz := gzip.NewWriter(&buf)
|
||
if _, err := gz.Write(data); err != nil {
|
||
_ = gz.Close()
|
||
return nil, err
|
||
}
|
||
if err := gz.Close(); err != nil {
|
||
return nil, err
|
||
}
|
||
return buf.Bytes(), nil
|
||
}
|
||
|
||
// GzipDecompress 解压缩数据。默认上限 100MB 防解压炸弹 OOM(C5b);
|
||
// 需解压更大文件请用 GzipDecompressWithOptions。
|
||
func GzipDecompress(data []byte) ([]byte, error) {
|
||
return GzipDecompressWithOptions(data, DecompressOptions{})
|
||
}
|
||
|
||
// GzipDecompressWithOptions 解压缩数据,可配置大小上限(C5b)。
|
||
func GzipDecompressWithOptions(data []byte, opts DecompressOptions) ([]byte, error) {
|
||
buf := bytes.NewReader(data)
|
||
gz, err := gzip.NewReader(buf)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer gz.Close()
|
||
|
||
limit := resolveLimit(opts.MaxBytes, defaultDecompressLimit)
|
||
var reader io.Reader = gz
|
||
if limit > 0 {
|
||
// 多读 1 字节用于判断是否超限。
|
||
reader = io.LimitReader(gz, limit+1)
|
||
}
|
||
out, err := io.ReadAll(reader)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if limit > 0 && int64(len(out)) > limit {
|
||
return nil, fmt.Errorf("解压后大小超过上限 %d 字节: %w", limit, ErrDecompressLimit)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// GzipCompressFile 压缩文件
|
||
func GzipCompressFile(src, dst string) error {
|
||
// #nosec G304 -- src/dst 为调用方提供的本地文件路径,压缩 API 固有语义,非不可信输入
|
||
srcFile, err := os.Open(src)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer srcFile.Close()
|
||
|
||
// #nosec G304 -- 同上,dst 为调用方指定输出路径
|
||
dstFile, err := os.Create(dst)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
gz := gzip.NewWriter(dstFile)
|
||
|
||
// 保留原文件名和时间戳
|
||
if info, err := srcFile.Stat(); err == nil {
|
||
gz.Name = info.Name()
|
||
gz.ModTime = info.ModTime()
|
||
}
|
||
|
||
_, copyErr := io.Copy(gz, srcFile)
|
||
// gz.Close 刷出 gzip 尾部(含 CRC/大小校验),失败说明归档损坏,必须向上传播(M16/B18)。
|
||
closeErr := gz.Close()
|
||
// dstFile.Close 失败(如延迟写盘)同样意味着归档可能不完整。
|
||
dstErr := dstFile.Close()
|
||
if copyErr != nil {
|
||
return copyErr
|
||
}
|
||
if closeErr != nil {
|
||
return closeErr
|
||
}
|
||
return dstErr
|
||
}
|
||
|
||
// GzipDecompressFile 解压文件。默认上限 100MB 防磁盘耗尽(C5b);
|
||
// 需解压更大文件请用 GzipDecompressFileWithOptions。
|
||
func GzipDecompressFile(src, dst string) error {
|
||
return GzipDecompressFileWithOptions(src, dst, DecompressOptions{})
|
||
}
|
||
|
||
// GzipDecompressFileWithOptions 解压文件,可配置大小上限(C5b)。
|
||
func GzipDecompressFileWithOptions(src, dst string, opts DecompressOptions) (err error) {
|
||
// #nosec G304 -- src 为调用方提供的本地文件路径,解压 API 固有语义,非不可信输入
|
||
srcFile, err := os.Open(src)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer srcFile.Close()
|
||
|
||
gz, err := gzip.NewReader(srcFile)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer gz.Close()
|
||
|
||
// #nosec G304 -- dst 为调用方指定输出路径,解压 API 固有语义
|
||
dstFile, err := os.Create(dst)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 dst,避免被拒炸弹遗留残片;
|
||
// 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。
|
||
defer func() {
|
||
if cerr := dstFile.Close(); cerr != nil {
|
||
err = errors.Join(err, cerr)
|
||
}
|
||
if err != nil {
|
||
_ = os.Remove(dst)
|
||
}
|
||
}()
|
||
|
||
limit := resolveLimit(opts.MaxBytes, defaultDecompressLimit)
|
||
var written int64
|
||
if limit > 0 {
|
||
// CopyN 最多读 limit+1 字节,超限即判定为炸弹。
|
||
written, err = io.CopyN(dstFile, gz, limit+1)
|
||
} else {
|
||
// #nosec G110 -- 仅当调用方显式 MaxBytes=-1 不限时走此分支,有限分支已用 CopyN 封顶防炸弹
|
||
written, err = io.Copy(dstFile, gz)
|
||
}
|
||
if err != nil && err != io.EOF {
|
||
return err
|
||
}
|
||
if limit > 0 && written > limit {
|
||
return fmt.Errorf("解压后大小超过上限 %d 字节: %w", limit, ErrDecompressLimit)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Zip 压缩文件或目录
|
||
// 参数: zipPath 目标zip文件路径,paths 要压缩的文件或目录列表
|
||
func Zip(zipPath string, paths []string) error {
|
||
// 创建目标目录
|
||
if err := os.MkdirAll(filepath.Dir(zipPath), 0750); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 创建 zip 文件
|
||
// #nosec G304 -- zipPath 为调用方指定输出路径,压缩 API 固有语义
|
||
archive, err := os.Create(zipPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
zipWriter := zip.NewWriter(archive)
|
||
|
||
walkErr := func() error {
|
||
for _, srcPath := range paths {
|
||
srcPath = strings.TrimSuffix(srcPath, string(os.PathSeparator))
|
||
|
||
// #nosec G122 -- 压缩调用方提供的源路径,非解压不可信输入;symlink 跟随是压缩场景的可接受行为
|
||
err := filepath.Walk(srcPath, func(path string, info fs.FileInfo, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 创建文件头
|
||
header, err := zip.FileInfoHeader(info)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
header.Method = zip.Deflate
|
||
|
||
// 设置相对路径
|
||
header.Name, err = filepath.Rel(filepath.Dir(srcPath), path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if info.IsDir() {
|
||
header.Name += string(os.PathSeparator)
|
||
}
|
||
|
||
writer, err := zipWriter.CreateHeader(header)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if info.IsDir() {
|
||
return nil
|
||
}
|
||
|
||
// #nosec G304,G122 -- path 为 Walk 遍历调用方源路径产生,非不可信输入;压缩场景接受 symlink 跟随语义。
|
||
file, err := os.Open(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer file.Close()
|
||
|
||
_, err = io.Copy(writer, file)
|
||
return err
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}()
|
||
|
||
// zipWriter.Close 刷出中央目录记录,失败说明归档损坏,必须向上传播(M16/B18)。
|
||
zipCloseErr := zipWriter.Close()
|
||
archiveCloseErr := archive.Close()
|
||
if walkErr != nil {
|
||
return walkErr
|
||
}
|
||
if zipCloseErr != nil {
|
||
return zipCloseErr
|
||
}
|
||
return archiveCloseErr
|
||
}
|
||
|
||
// Unzip 解压 zip 文件。默认启用 Zip-Slip 防护(前缀锚定 + 拒绝符号链接),
|
||
// 单条目上限 100MB、累计上限 1GB 防解压炸弹(C5b);需自定义上限请用 UnzipWithOptions。
|
||
func Unzip(zipPath, dstDir string) error {
|
||
return UnzipWithOptions(zipPath, dstDir, DecompressOptions{})
|
||
}
|
||
|
||
// UnzipWithOptions 解压 zip 文件,可配置大小上限(C5b)。Zip-Slip 防护始终启用。
|
||
func UnzipWithOptions(zipPath, dstDir string, opts DecompressOptions) error {
|
||
reader, err := zip.OpenReader(zipPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer reader.Close()
|
||
|
||
// 用绝对路径作目标锚定根,避免相对路径 + `..` 组合绕过前缀校验(C5a)。
|
||
absDst, err := filepath.Abs(dstDir)
|
||
if err != nil {
|
||
return fmt.Errorf("resolve unzip destination failed: %w", err)
|
||
}
|
||
absDst = filepath.Clean(absDst)
|
||
|
||
entryLimit := resolveLimit(opts.MaxBytes, defaultDecompressLimit)
|
||
totalLimit := resolveLimit(opts.MaxTotalBytes, defaultDecompressTotalLimit)
|
||
var total int64
|
||
|
||
for _, file := range reader.File {
|
||
written, err := unzipFile(file, absDst, entryLimit, totalLimit, total)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
total += written
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// unzipFile 解压单个 zip 条目到 absDst 下。
|
||
// entryLimit: 单条目上限(-1 不限);totalLimit: 累计上限(-1 不限);accrued: 已解压累计字节。
|
||
// 返回本条目写入字节数。
|
||
func unzipFile(file *zip.File, absDst string, entryLimit, totalLimit, accrued int64) (written int64, err error) {
|
||
// 拒绝符号链接条目,防经软链二次穿越(C5a)。
|
||
if file.Mode()&os.ModeSymlink != 0 {
|
||
return 0, fmt.Errorf("条目 %s 为符号链接: %w", file.Name, ErrSymlinkEntry)
|
||
}
|
||
|
||
// zip 条目名规范用正斜杠;转成当前平台分隔符后再 Join,并以前缀锚定拒绝 `..` 逃逸(C5a)。
|
||
name := filepath.FromSlash(file.Name)
|
||
// 拒绝绝对路径与以分隔符开头的条目(非标准、可疑,避免平台语义差异)。
|
||
// filepath.IsAbs 在 Windows 不认 "/x"(无盘符)为绝对路径,故补充分隔符前缀检查。
|
||
if filepath.IsAbs(name) || strings.HasPrefix(name, string(os.PathSeparator)) || strings.HasPrefix(file.Name, "/") {
|
||
return 0, fmt.Errorf("条目 %s 为绝对路径: %w", file.Name, ErrPathTraversal)
|
||
}
|
||
target := filepath.Join(absDst, name)
|
||
if target == absDst || !strings.HasPrefix(target, absDst+string(os.PathSeparator)) {
|
||
return 0, fmt.Errorf("条目 %s 逃逸目标目录: %w", file.Name, ErrPathTraversal)
|
||
}
|
||
|
||
if file.FileInfo().IsDir() {
|
||
if err := os.MkdirAll(target, 0750); err != nil {
|
||
return 0, err
|
||
}
|
||
return 0, nil
|
||
}
|
||
|
||
// 创建父目录
|
||
if err := os.MkdirAll(filepath.Dir(target), 0750); err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
// 打开 zip 中的文件
|
||
rc, err := file.Open()
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer rc.Close()
|
||
|
||
// 创建目标文件
|
||
// #nosec G304 -- target 经前缀锚定校验(absDst+sep),已防 Zip-Slip 逃逸
|
||
dstFile, err := os.Create(target)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
// L-A 修复:失败时(超限/拷贝错误)清理部分落盘的 target,避免被拒炸弹遗留残片;
|
||
// 成功时仅关闭。os.Remove 在 Close 之后,兼容 Windows(删除打开中的文件会失败)。
|
||
defer func() {
|
||
if cerr := dstFile.Close(); cerr != nil {
|
||
err = errors.Join(err, cerr)
|
||
}
|
||
if err != nil {
|
||
_ = os.Remove(target)
|
||
}
|
||
}()
|
||
|
||
// 计算本次拷贝上限:单条目上限与累计剩余上限中较小者(-1 视为无限)。
|
||
// remaining: 累计剩余(-1 表示累计不限);totalLimit>0 时若已无剩余,直接判超限。
|
||
remaining := int64(-1)
|
||
if totalLimit > 0 {
|
||
remaining = totalLimit - accrued
|
||
if remaining <= 0 {
|
||
return 0, fmt.Errorf("累计解压超过上限 %d 字节: %w", totalLimit, ErrDecompressLimit)
|
||
}
|
||
}
|
||
cap := minLimit(entryLimit, remaining)
|
||
// cap 为 -1(两者皆不限)或 >0(有限上限,剩余已保证 >0),不会是 0。
|
||
|
||
if cap > 0 {
|
||
written, err = io.CopyN(dstFile, rc, cap+1)
|
||
} else {
|
||
// #nosec G110 -- 仅当调用方显式 MaxBytes=-1 不限时走此分支,有限分支已用 CopyN 封顶防炸弹
|
||
written, err = io.Copy(dstFile, rc)
|
||
}
|
||
if err != nil && err != io.EOF {
|
||
return written, err
|
||
}
|
||
if cap > 0 && written > cap {
|
||
return written, fmt.Errorf("条目 %s 超过解压上限 %d 字节: %w", file.Name, cap, ErrDecompressLimit)
|
||
}
|
||
return written, nil
|
||
}
|