Private
Public Access
1
0
Files
u-desk/internal/filesystem/zip.go

350 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package filesystem
import (
"archive/zip"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
)
// ZipFileEntry 表示 zip 文件中的一个文件条目
type ZipFileEntry struct {
Name string
Path string // 在 zip 中的完整路径
Size int64
Modified string
IsDir bool
Method string // 压缩方法 (Store/Deflate)
}
// validateZipPath 验证 ZIP 文件路径是否有效
// 统一的路径验证逻辑,避免在多个函数中重复
func validateZipPath(zipPath string) error {
if !isSafePath(zipPath) {
return fmt.Errorf("zip 路径不安全")
}
// 检查 zip 文件是否存在
if _, err := os.Stat(zipPath); os.IsNotExist(err) {
return fmt.Errorf("zip 文件不存在")
}
return nil
}
// debugLog 条件日志记录,仅在调试模式下输出
// 通过设置环境变量 UDESK_ZIP_DEBUG=1 启用调试日志
var zipDebugMode = os.Getenv("UDESK_ZIP_DEBUG") == "1"
func debugLog(format string, args ...interface{}) {
if zipDebugMode {
log.Printf(format, args...)
}
}
// ListZipContents 列出 zip 文件内容
// 🔒 安全增强添加ZIP炸弹防护、路径遍历检查
func ListZipContents(zipPath string) ([]map[string]interface{}, error) {
debugLog("[ListZipContents] 开始处理 ZIP 文件: %s", zipPath)
// 统一验证路径
if err := validateZipPath(zipPath); err != nil {
debugLog("[ListZipContents] 路径验证失败: %v", err)
return nil, err
}
// 检查文件是否存在
fileInfo, err := os.Stat(zipPath)
if err != nil {
debugLog("[ListZipContents] 文件状态检查失败: %v", err)
return nil, fmt.Errorf("无法访问文件: %v", err)
}
debugLog("[ListZipContents] 文件信息: 大小=%d bytes, 权限=%v", fileInfo.Size(), fileInfo.Mode())
// 🔒 安全检查:检查文件大小(太小或太大)
if fileInfo.Size() < 22 {
debugLog("[ListZipContents] 文件太小,可能不是有效的 ZIP 文件: %d bytes", fileInfo.Size())
return nil, fmt.Errorf("文件太小 (%d bytes),可能不是有效的 ZIP 文件", fileInfo.Size())
}
// 🔒 安全检查ZIP炸弹防护检查文件大小
if fileInfo.Size() > MaxZipSize {
debugLog("[ListZipContents] ZIP文件过大: %d bytes", fileInfo.Size())
return nil, fmt.Errorf("ZIP文件过大 (%d bytes),超过限制 (%d bytes)", fileInfo.Size(), MaxZipSize)
}
// 检查文件是否可读
file, err := os.Open(zipPath)
if err != nil {
debugLog("[ListZipContents] 无法打开文件: %v", err)
return nil, fmt.Errorf("无法打开文件: %v", err)
}
// 读取前 4 个字节检查 ZIP 文件头
header := make([]byte, 4)
n, err := file.Read(header)
file.Close()
if err != nil || n != 4 {
debugLog("[ListZipContents] 无法读取文件头: n=%d, err=%v", n, err)
return nil, fmt.Errorf("无法读取文件头")
}
debugLog("[ListZipContents] 文件头: 0x%02x 0x%02x 0x%02x 0x%02x", header[0], header[1], header[2], header[3])
// ZIP 文件应该是 PK\x03\x04 或 PK\x05\x06 (空 ZIP)
if header[0] != 0x50 || header[1] != 0x4B { // 'P' 'K'
debugLog("[ListZipContents] 文件头签名错误,不是有效的 ZIP 文件")
return nil, fmt.Errorf("文件头签名错误,不是有效的 ZIP 文件 (可能是其他格式或已损坏)")
}
// 打开 zip 文件
debugLog("[ListZipContents] 尝试打开 ZIP 读取器...")
reader, err := zip.OpenReader(zipPath)
if err != nil {
debugLog("[ListZipContents] 打开 ZIP 失败: %v", err)
debugLog("[ListZipContents] 错误类型: %T", err)
// 提供更详细的错误信息和解决建议
errMsg := fmt.Sprintf("打开 zip 文件失败: %v", err)
if strings.Contains(err.Error(), "not a valid zip file") {
errMsg += "\n\n可能的原因:\n" +
"1. 文件已损坏或不完整\n" +
"2. 不是标准的 ZIP 格式\n" +
"3. 文件正在被其他程序占用(如压缩软件)\n" +
"4. 使用了特殊的压缩方式\n\n" +
"建议解决方法:\n" +
"- 关闭所有可能打开该文件的程序\n" +
"- 尝试用 7-Zip 或 WinRAR 重新压缩\n" +
"- 检查文件大小是否正常(不是 0 字节)\n" +
"- 如果是从网络下载的,尝试重新下载"
}
return nil, fmt.Errorf("%s", errMsg)
}
defer reader.Close()
debugLog("[ListZipContents] 成功打开 ZIP开始读取文件列表...")
// 🔒 安全检查ZIP炸弹防护检查解压后总大小
var totalUncompressed int64
for _, file := range reader.File {
totalUncompressed += int64(file.UncompressedSize64)
}
if totalUncompressed > MaxExtractSize {
debugLog("[ListZipContents] 解压后总大小过大: %d bytes", totalUncompressed)
return nil, fmt.Errorf("解压后总大小过大 (%d bytes),超过限制 (%d bytes)", totalUncompressed, MaxExtractSize)
}
var result []map[string]interface{}
fileCount := 0
dirCount := 0
// 遍历 zip 文件中的所有文件
for _, file := range reader.File {
// 跳过 macOS 资源分支文件
if strings.HasPrefix(file.Name, "__MACOSX/") {
continue
}
// 🔒 安全检查:路径遍历攻击防护
if strings.Contains(file.Name, "..") {
debugLog("[ListZipContents] 检测到路径遍历尝试: %s", file.Name)
return nil, fmt.Errorf("ZIP文件包含不安全的路径: %s", file.Name)
}
// 🔒 安全检查:绝对路径防护
if filepath.IsAbs(file.Name) {
debugLog("[ListZipContents] 检测到绝对路径: %s", file.Name)
return nil, fmt.Errorf("ZIP文件包含绝对路径: %s", file.Name)
}
isDir := file.Mode().IsDir()
name := filepath.Base(file.Name)
// 对于目录,使用目录名;对于文件,使用文件名
if isDir {
name = file.Name
dirCount++
} else {
fileCount++
}
// 压缩方法描述
method := "Store"
if file.Method == 8 {
method = "Deflate"
}
entry := map[string]interface{}{
"name": name,
"path": file.Name, // zip 中的完整路径(已使用 /
"is_dir": isDir,
"size": file.UncompressedSize64,
"compressed": file.CompressedSize64,
"mod_time": file.Modified.Format("2006-01-02 15:04:05"),
"method": method,
}
result = append(result, entry)
}
debugLog("[ListZipContents] 读取完成: %d 个文件, %d 个目录", fileCount, dirCount)
return result, nil
}
// ExtractFileFromZip 从 zip 文件中提取单个文件内容
// 优化:使用通用包装器,消除重复代码
func ExtractFileFromZip(zipPath, filePath string) (string, error) {
result, err := withZipFile(zipPath, filePath, func(file *zip.File) (interface{}, error) {
// 打开文件
rc, err := file.Open()
if err != nil {
return nil, fmt.Errorf("打开 zip 中的文件失败: %v", err)
}
defer rc.Close()
// 读取内容
data, err := readAllFromFile(rc)
if err != nil {
return nil, fmt.Errorf("读取文件内容失败: %v", err)
}
return string(data), nil
})
if err != nil {
return "", err
}
return result.(string), nil
}
// ExtractFileFromZipToTemp 从 zip 文件中提取单个文件到临时目录
// 返回临时文件的完整路径
// 适用于提取图片等二进制文件
// 优化:使用通用包装器,消除重复代码
func ExtractFileFromZipToTemp(zipPath, filePath string) (string, error) {
debugLog("[ExtractFileFromZipToTemp] 开始提取: %s from %s", filePath, zipPath)
// 启动临时文件清理协程
go CleanOldTempFiles()
// 创建临时目录
tempDir := filepath.Join(os.TempDir(), TempFileDir)
if err := os.MkdirAll(tempDir, DefaultDirPermissions); err != nil {
return "", fmt.Errorf("创建临时目录失败: %v", err)
}
result, err := withZipFile(zipPath, filePath, func(file *zip.File) (interface{}, error) {
// 安全检查:文件大小限制
if file.UncompressedSize64 > MaxSingleFileSize {
debugLog("[ExtractFileFromZipToTemp] 文件过大: %d bytes", file.UncompressedSize64)
return nil, fmt.Errorf("文件过大 (%d bytes),超过限制 (%d bytes)",
file.UncompressedSize64, MaxSingleFileSize)
}
// 打开文件
rc, err := file.Open()
if err != nil {
return nil, fmt.Errorf("打开 zip 中的文件失败: %v", err)
}
defer rc.Close()
// 生成临时文件名
tempFileName := fmt.Sprintf("%d_%s", time.Now().UnixNano(), filepath.Base(file.Name))
tempFilePath := filepath.Join(tempDir, tempFileName)
// 创建临时文件
outFile, err := os.Create(tempFilePath)
if err != nil {
return nil, fmt.Errorf("创建临时文件失败: %v", err)
}
defer outFile.Close()
// 限制写入大小
limitedReader := &io.LimitedReader{R: rc, N: MaxSingleFileSize}
written, err := io.Copy(outFile, limitedReader)
if err != nil {
os.Remove(tempFilePath)
return nil, fmt.Errorf("写入临时文件失败: %v", err)
}
// 检查是否超过限制
if limitedReader.N <= 0 {
os.Remove(tempFilePath)
return nil, fmt.Errorf("文件大小超过限制")
}
debugLog("[ExtractFileFromZipToTemp] 提取成功: %s -> %s (%d bytes)",
file.Name, tempFilePath, written)
return tempFilePath, nil
})
if err != nil {
return "", err
}
return result.(string), nil
}
// CleanOldTempFiles 清理超过指定时间的临时文件
// 🔒 新增:防止临时文件累积占用磁盘空间
func CleanOldTempFiles() {
tempDir := filepath.Join(os.TempDir(), "u-desk-zip")
// 检查临时目录是否存在
dir, err := os.Open(tempDir)
if err != nil {
// 目录不存在或其他错误,无需清理
return
}
defer dir.Close()
// 读取目录内容
files, err := dir.Readdir(-1)
if err != nil {
return
}
cleanedCount := 0
now := time.Now()
for _, file := range files {
// 跳过目录
if file.IsDir() {
continue
}
// 检查文件年龄
if now.Sub(file.ModTime()) > TempFileCleanupAge {
filePath := filepath.Join(tempDir, file.Name())
if err := os.Remove(filePath); err == nil {
cleanedCount++
debugLog("[CleanOldTempFiles] 清理临时文件: %s (年龄: %v)", file.Name(), now.Sub(file.ModTime()))
}
}
}
if cleanedCount > 0 {
debugLog("[CleanOldTempFiles] 清理完成: 共清理 %d 个临时文件", cleanedCount)
}
}
// GetZipFileInfo 获取 zip 文件中特定文件的信息
// 优化:使用通用包装器,消除重复代码
func GetZipFileInfo(zipPath, filePath string) (map[string]interface{}, error) {
result, err := withZipFile(zipPath, filePath, func(file *zip.File) (interface{}, error) {
return createFileInfoMap(file, true), nil
})
if err != nil {
return nil, err
}
return result.(map[string]interface{}), nil
}