package archiver import ( "archive/tar" "compress/gzip" "context" "crypto/tls" "fmt" "io" "net/http" "os" "path/filepath" "strings" "gitea.loveuer.com/yizhisec/pkg3/logger" ) // Options defines options for downloading and extracting archives type Options struct { // InsecureSkipVerify skips TLS certificate verification (equivalent to wget --no-check-certificate) InsecureSkipVerify bool // HTTPClient allows providing a custom HTTP client HTTPClient *http.Client // OnProgress is called during extraction with the current file being extracted OnProgress func(filename string, index int, total int) // IsGzipped explicitly specifies whether the archive is gzip compressed // If nil, auto-detect based on file extension (.tar.gz, .tgz) IsGzipped *bool } // Option is a functional option for configuring the archiver type Option func(*Options) // WithInsecureSkipVerify skips TLS certificate verification func WithInsecureSkipVerify() Option { return func(o *Options) { o.InsecureSkipVerify = true } } // WithHTTPClient sets a custom HTTP client func WithHTTPClient(client *http.Client) Option { return func(o *Options) { o.HTTPClient = client } } // WithProgress sets a progress callback func WithProgress(callback func(filename string, index int, total int)) Option { return func(o *Options) { o.OnProgress = callback } } // WithGzipCompression explicitly sets whether the archive is gzip compressed func WithGzipCompression(isGzipped bool) Option { return func(o *Options) { o.IsGzipped = &isGzipped } } // DownloadAndExtract downloads a tar or tar.gz file from URL and extracts it to destDir // Supports both .tar and .tar.gz formats func DownloadAndExtract(ctx context.Context, url, destDir string, opts ...Option) error { options := &Options{ InsecureSkipVerify: false, } for _, opt := range opts { opt(options) } logger.Debug("开始下载和解压: %s -> %s", url, destDir) // Create HTTP client client := options.HTTPClient if client == nil { client = &http.Client{} if options.InsecureSkipVerify { logger.Debug("TLS 证书验证已禁用") client.Transport = &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } } } // Download the file logger.Debug("发起 HTTP 请求: %s", url) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { logger.Debug("创建请求失败: %v", err) return fmt.Errorf("failed to create request: %w", err) } resp, err := client.Do(req) if err != nil { logger.Debug("下载失败: %v", err) return fmt.Errorf("failed to download: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { logger.Debug("HTTP 状态码异常: %d", resp.StatusCode) return fmt.Errorf("bad status: %s", resp.Status) } logger.Debug("下载成功,准备解压") // Determine if the file is gzipped var reader io.Reader = resp.Body var isGzipped bool if options.IsGzipped != nil { // Explicitly specified by option isGzipped = *options.IsGzipped logger.Debug("压缩格式由选项指定: isGzipped=%v", isGzipped) } else { // Auto-detect based on file extension isGzipped = strings.HasSuffix(url, ".tar.gz") || strings.HasSuffix(url, ".tgz") logger.Debug("根据文件扩展名自动检测压缩格式: isGzipped=%v", isGzipped) } if isGzipped { logger.Debug("使用 gzip 解压") gzReader, err := gzip.NewReader(resp.Body) if err != nil { logger.Debug("创建 gzip reader 失败: %v", err) return fmt.Errorf("failed to create gzip reader: %w", err) } defer gzReader.Close() reader = gzReader } else { logger.Debug("tar 格式(未压缩)") } // Extract tar archive if err := extractTar(reader, destDir, options); err != nil { return fmt.Errorf("failed to extract tar: %w", err) } logger.Debug("解压完成") return nil } // extractTar extracts a tar archive to the destination directory func extractTar(r io.Reader, destDir string, options *Options) error { tarReader := tar.NewReader(r) fileCount := 0 totalFiles := 0 // First pass: count total files (optional, for progress reporting) // For now, we'll just extract directly for { header, err := tarReader.Next() if err == io.EOF { break } if err != nil { logger.Debug("读取 tar 条目失败: %v", err) return fmt.Errorf("failed to read tar header: %w", err) } target := filepath.Join(destDir, header.Name) logger.Debug("解压文件: %s", header.Name) // Call progress callback if provided if options.OnProgress != nil { options.OnProgress(header.Name, fileCount, totalFiles) } switch header.Typeflag { case tar.TypeDir: // Create directory if err := os.MkdirAll(target, os.FileMode(header.Mode)); err != nil { logger.Debug("创建目录失败 %s: %v", target, err) return fmt.Errorf("failed to create directory %s: %w", target, err) } case tar.TypeReg: // Create regular file // Ensure parent directory exists if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { logger.Debug("创建父目录失败 %s: %v", filepath.Dir(target), err) return fmt.Errorf("failed to create parent directory: %w", err) } outFile, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR|os.O_TRUNC, os.FileMode(header.Mode)) if err != nil { logger.Debug("创建文件失败 %s: %v", target, err) return fmt.Errorf("failed to create file %s: %w", target, err) } if _, err := io.Copy(outFile, tarReader); err != nil { outFile.Close() logger.Debug("写入文件失败 %s: %v", target, err) return fmt.Errorf("failed to write file %s: %w", target, err) } outFile.Close() fileCount++ case tar.TypeSymlink: // Create symbolic link if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { logger.Debug("创建符号链接父目录失败 %s: %v", filepath.Dir(target), err) return fmt.Errorf("failed to create parent directory for symlink: %w", err) } // Remove existing file/link if exists os.Remove(target) if err := os.Symlink(header.Linkname, target); err != nil { logger.Debug("创建符号链接失败 %s -> %s: %v", target, header.Linkname, err) return fmt.Errorf("failed to create symlink %s -> %s: %w", target, header.Linkname, err) } fileCount++ default: logger.Debug("跳过不支持的文件类型: %s (type: %v)", header.Name, header.Typeflag) } } logger.Debug("解压完成,共 %d 个文件", fileCount) return nil }