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 }