244 lines
6.9 KiB
Go
244 lines
6.9 KiB
Go
|
|
package file
|
||
|
|
|
||
|
|
import (
|
||
|
|
"archive/tar"
|
||
|
|
"archive/zip"
|
||
|
|
"compress/gzip"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"github.com/bodgit/sevenzip"
|
||
|
|
"github.com/dsnet/compress/bzip2"
|
||
|
|
)
|
||
|
|
|
||
|
|
// CompressionType 定义了支持的压缩文件类型枚举。
|
||
|
|
// 每个枚举值都是一个唯一的文件后缀。
|
||
|
|
type CompressionType string
|
||
|
|
|
||
|
|
const (
|
||
|
|
ZIP CompressionType = ".zip"
|
||
|
|
TAR CompressionType = ".tar"
|
||
|
|
TARGZ CompressionType = ".tar.gz"
|
||
|
|
TGZ CompressionType = ".tgz"
|
||
|
|
TARBZ2 CompressionType = ".tar.bz2"
|
||
|
|
TBZ2 CompressionType = ".tbz2"
|
||
|
|
SEVEN_Z CompressionType = ".7z"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Matches 判断文件名是否以当前压缩类型定义的后缀结尾。
|
||
|
|
func (ct CompressionType) Matches(filename string) bool {
|
||
|
|
return strings.HasSuffix(strings.ToLower(filename), string(ct))
|
||
|
|
}
|
||
|
|
|
||
|
|
// DecompressAtomic 以原子方式将指定的压缩包解压到目标目录。
|
||
|
|
// 此函数是线程安全的,它会获取全局文件锁。
|
||
|
|
//
|
||
|
|
// 核心保障:如果解压过程中发生任何错误,它会自动删除整个 destPath 目录以进行回滚。
|
||
|
|
//
|
||
|
|
// sourcePath: 要解压的压缩包的完整路径。
|
||
|
|
// destPath: 目标解压目录。此函数将创建此目录,并将所有内容解压到其中。
|
||
|
|
//
|
||
|
|
// 重要警告:请勿将一个已存在的、包含重要文件的目录作为 destPath。
|
||
|
|
// 此函数假定它拥有对 destPath 的完全控制权,并会在失败时将其彻底删除。
|
||
|
|
func DecompressAtomic(sourcePath, destPath string) error {
|
||
|
|
action := func() error {
|
||
|
|
return decompress(sourcePath, destPath)
|
||
|
|
}
|
||
|
|
|
||
|
|
onRollback := func(err error) {
|
||
|
|
_ = os.RemoveAll(destPath)
|
||
|
|
}
|
||
|
|
|
||
|
|
return ExecuteWithLock(action, onRollback)
|
||
|
|
}
|
||
|
|
|
||
|
|
// decompress 是解压操作的核心实现,它本身不是线程安全的,也不提供回滚。
|
||
|
|
// 它应该被 DecompressAtomic 或其他调用方在 ExecuteWithLock 的回调中执行。
|
||
|
|
func decompress(sourcePath, destPath string) error {
|
||
|
|
// 确保目标目录存在
|
||
|
|
if err := os.MkdirAll(destPath, 0755); err != nil {
|
||
|
|
return fmt.Errorf("创建目标目录 %s 失败: %w", destPath, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 直接在 switch 中使用 Matches 方法
|
||
|
|
switch {
|
||
|
|
case ZIP.Matches(sourcePath):
|
||
|
|
return decompressZip(sourcePath, destPath)
|
||
|
|
case TAR.Matches(sourcePath),
|
||
|
|
TARGZ.Matches(sourcePath),
|
||
|
|
TGZ.Matches(sourcePath),
|
||
|
|
TARBZ2.Matches(sourcePath),
|
||
|
|
TBZ2.Matches(sourcePath):
|
||
|
|
return decompressTar(sourcePath, destPath)
|
||
|
|
case SEVEN_Z.Matches(sourcePath):
|
||
|
|
return decompress7z(sourcePath, destPath)
|
||
|
|
default:
|
||
|
|
return fmt.Errorf("不支持的压缩文件类型: %s", sourcePath)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// secureJoinPath 安全地将子路径连接到目标目录,并检查路径遍历攻击。
|
||
|
|
func secureJoinPath(destDir, subPath string) (string, error) {
|
||
|
|
destFilePath := filepath.Join(destDir, subPath)
|
||
|
|
if !strings.HasPrefix(destFilePath, filepath.Clean(destDir)+string(os.PathSeparator)) {
|
||
|
|
return "", fmt.Errorf("检测到不安全的解压路径: %s", subPath)
|
||
|
|
}
|
||
|
|
return destFilePath, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// createFileFromReader 从一个 reader 创建并写入文件内容。
|
||
|
|
func createFileFromReader(filePath string, mode os.FileMode, reader io.Reader) error {
|
||
|
|
// 确保父目录存在
|
||
|
|
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
|
||
|
|
return fmt.Errorf("为文件 %s 创建父目录失败: %w", filePath, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 创建目标文件
|
||
|
|
destFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("创建目标文件 %s 失败: %w", filePath, err)
|
||
|
|
}
|
||
|
|
defer destFile.Close()
|
||
|
|
|
||
|
|
// 复制内容
|
||
|
|
if _, err = io.Copy(destFile, reader); err != nil {
|
||
|
|
return fmt.Errorf("写入文件 %s 内容失败: %w", filePath, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// decompressZip 负责解压 ZIP 格式的压缩包。
|
||
|
|
func decompressZip(sourcePath, destPath string) error {
|
||
|
|
reader, err := zip.OpenReader(sourcePath)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("打开 ZIP 文件 %s 失败: %w", sourcePath, err)
|
||
|
|
}
|
||
|
|
defer reader.Close()
|
||
|
|
|
||
|
|
for _, f := range reader.File {
|
||
|
|
destFilePath, err := secureJoinPath(destPath, f.Name)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
if f.FileInfo().IsDir() {
|
||
|
|
if err := os.MkdirAll(destFilePath, f.Mode()); err != nil {
|
||
|
|
return fmt.Errorf("创建 ZIP 内目录 %s 失败: %w", destFilePath, err)
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
srcFile, err := f.Open()
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("打开 ZIP 内文件 %s 失败: %w", f.Name, err)
|
||
|
|
}
|
||
|
|
defer srcFile.Close()
|
||
|
|
|
||
|
|
if err := createFileFromReader(destFilePath, f.Mode(), srcFile); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// processTarStream 是处理 tar 流的核心逻辑。
|
||
|
|
func processTarStream(tarReader *tar.Reader, destPath string) error {
|
||
|
|
for {
|
||
|
|
header, err := tarReader.Next()
|
||
|
|
if err == io.EOF {
|
||
|
|
break // 归档结束
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("读取 tar 头部失败: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
destFilePath, err := secureJoinPath(destPath, header.Name)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
switch header.Typeflag {
|
||
|
|
case tar.TypeDir:
|
||
|
|
if err := os.MkdirAll(destFilePath, os.FileMode(header.Mode)); err != nil {
|
||
|
|
return fmt.Errorf("创建 tar 内目录 %s 失败: %w", destFilePath, err)
|
||
|
|
}
|
||
|
|
case tar.TypeReg:
|
||
|
|
if err := createFileFromReader(destFilePath, os.FileMode(header.Mode), tarReader); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// decompressTar 负责解压 TAR 格式的归档文件。
|
||
|
|
func decompressTar(sourcePath, destPath string) error {
|
||
|
|
file, err := os.Open(sourcePath)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("打开 tar 文件 %s 失败: %w", sourcePath, err)
|
||
|
|
}
|
||
|
|
defer file.Close()
|
||
|
|
|
||
|
|
var streamReader io.Reader = file
|
||
|
|
lowerPath := strings.ToLower(sourcePath)
|
||
|
|
|
||
|
|
if TARGZ.Matches(lowerPath) || TGZ.Matches(lowerPath) {
|
||
|
|
gzReader, err := gzip.NewReader(file)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("创建 gzip 读取器失败: %w", err)
|
||
|
|
}
|
||
|
|
defer gzReader.Close()
|
||
|
|
streamReader = gzReader
|
||
|
|
} else if TARBZ2.Matches(lowerPath) || TBZ2.Matches(lowerPath) {
|
||
|
|
bz2Reader, err := bzip2.NewReader(file, nil)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("创建 bzip2 读取器失败: %w", err)
|
||
|
|
}
|
||
|
|
defer bz2Reader.Close()
|
||
|
|
streamReader = bz2Reader
|
||
|
|
}
|
||
|
|
|
||
|
|
tarReader := tar.NewReader(streamReader)
|
||
|
|
return processTarStream(tarReader, destPath)
|
||
|
|
}
|
||
|
|
|
||
|
|
// decompress7z 负责解压 7z 格式的压缩包。
|
||
|
|
func decompress7z(sourcePath, destPath string) error {
|
||
|
|
reader, err := sevenzip.OpenReader(sourcePath)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("打开 7z 文件 %s 失败: %w", sourcePath, err)
|
||
|
|
}
|
||
|
|
defer reader.Close()
|
||
|
|
|
||
|
|
for _, f := range reader.File {
|
||
|
|
destFilePath, err := secureJoinPath(destPath, f.Name)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
if f.FileInfo().IsDir() {
|
||
|
|
if err := os.MkdirAll(destFilePath, f.Mode()); err != nil {
|
||
|
|
return fmt.Errorf("创建 7z 内目录 %s 失败: %w", destFilePath, err)
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
srcFile, err := f.Open()
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("打开 7z 内文件 %s 失败: %w", f.Name, err)
|
||
|
|
}
|
||
|
|
defer srcFile.Close()
|
||
|
|
|
||
|
|
if err := createFileFromReader(destFilePath, f.Mode(), srcFile); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|