mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-06 00:25:43 +00:00
Move path sanitization before directory handling in extractFile so directory entries with "../" prefixes are rejected the same way file entries already are. Add the same sanitization to extractSevenZipMultiPart and both zip extraction loops in the Windows updater, which had no path validation at all.
476 lines
15 KiB
Go
476 lines
15 KiB
Go
package download
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"github.com/mholt/archives"
|
|
"golang.org/x/text/encoding/simplifiedchinese"
|
|
)
|
|
|
|
// supportedArchiveExtensions contains file extensions supported by mholt/archives library
|
|
var supportedArchiveExtensions = []string{
|
|
// Archive formats
|
|
".zip",
|
|
".tar",
|
|
".tar.gz", ".tgz",
|
|
".tar.bz2", ".tbz2",
|
|
".tar.xz", ".txz",
|
|
".tar.lz4", ".tlz4",
|
|
".tar.sz", ".tsz",
|
|
".tar.zst", ".tzst",
|
|
".rar",
|
|
".7z",
|
|
// Compression formats
|
|
".gz",
|
|
".bz2",
|
|
".xz",
|
|
".lz4",
|
|
".sz",
|
|
".zst",
|
|
".br",
|
|
".lz",
|
|
}
|
|
|
|
// multiPartArchivePatterns contains regex patterns for multi-part archive detection
|
|
// Each pattern should have a capture group for the base name and part number
|
|
var multiPartArchivePatterns = []*regexp.Regexp{
|
|
// 7z multi-part: file.7z.001, file.7z.002, etc.
|
|
regexp.MustCompile(`(?i)^(.+\.7z)\.(\d{3})$`),
|
|
// RAR multi-part (new style): file.part01.rar, file.part02.rar, etc.
|
|
regexp.MustCompile(`(?i)^(.+)\.part(\d+)\.rar$`),
|
|
// RAR multi-part (old style): file.rar, file.r00, file.r01, etc. (first file is .rar)
|
|
regexp.MustCompile(`(?i)^(.+)\.r(\d{2})$`),
|
|
// ZIP multi-part: file.zip.001, file.zip.002, etc.
|
|
regexp.MustCompile(`(?i)^(.+\.zip)\.(\d{3})$`),
|
|
// ZIP split: file.z01, file.z02, ... file.zip (last file is .zip)
|
|
regexp.MustCompile(`(?i)^(.+)\.z(\d{2})$`),
|
|
}
|
|
|
|
// ArchivePartInfo contains information about a multi-part archive
|
|
type ArchivePartInfo struct {
|
|
// IsMultiPart indicates if this file is part of a multi-part archive
|
|
IsMultiPart bool
|
|
// BaseName is the common base name for all parts (without part number extension)
|
|
BaseName string
|
|
// PartNumber is the part number (1-indexed)
|
|
PartNumber int
|
|
// FirstPartPath is the path to the first part of the archive
|
|
FirstPartPath string
|
|
// Pattern indicates which pattern matched (for determining extraction method)
|
|
Pattern string
|
|
}
|
|
|
|
// ExtractProgressCallback is called to report extraction progress
|
|
type ExtractProgressCallback func(extractedFiles int, totalFiles int, progress int)
|
|
|
|
// newZipFormat creates a Zip format with proper character encoding support.
|
|
// It uses GB18030 encoding to handle Chinese characters in filenames that may
|
|
// be encoded with legacy GBK/GB18030 instead of UTF-8.
|
|
func newZipFormat() archives.Zip {
|
|
return archives.Zip{
|
|
// GB18030 is a superset of GBK and handles Chinese characters correctly
|
|
TextEncoding: simplifiedchinese.GB18030,
|
|
}
|
|
}
|
|
|
|
// isArchiveFile checks if a file is a supported archive format
|
|
func isArchiveFile(filename string) bool {
|
|
lowerName := strings.ToLower(filename)
|
|
// Check for multi-part archive first
|
|
if isMultiPartArchive(filename) {
|
|
return true
|
|
}
|
|
return slices.ContainsFunc(supportedArchiveExtensions, func(ext string) bool {
|
|
return strings.HasSuffix(lowerName, ext)
|
|
})
|
|
}
|
|
|
|
// archiveInfo holds information about an opened archive
|
|
type archiveInfo struct {
|
|
file *os.File
|
|
stat os.FileInfo
|
|
format archives.Format
|
|
input io.Reader
|
|
}
|
|
|
|
// openArchive opens an archive file and identifies its format
|
|
func openArchive(archivePath string, password string) (*archiveInfo, error) {
|
|
file, err := os.Open(archivePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stat, err := file.Stat()
|
|
if err != nil {
|
|
file.Close()
|
|
return nil, err
|
|
}
|
|
|
|
format, input, err := archives.Identify(context.Background(), archivePath, file)
|
|
if err != nil {
|
|
file.Close()
|
|
return nil, err
|
|
}
|
|
|
|
// Configure format-specific settings
|
|
// Handle password-protected archives and character encoding
|
|
if password != "" {
|
|
if rar, ok := format.(archives.Rar); ok {
|
|
rar.Password = password
|
|
format = rar
|
|
}
|
|
if sz, ok := format.(archives.SevenZip); ok {
|
|
sz.Password = password
|
|
format = sz
|
|
}
|
|
}
|
|
|
|
// For ZIP files, configure character encoding to handle non-UTF8 filenames
|
|
// This is essential for Chinese characters encoded in GBK/GB18030
|
|
if _, ok := format.(archives.Zip); ok {
|
|
format = newZipFormat()
|
|
}
|
|
|
|
return &archiveInfo{
|
|
file: file,
|
|
stat: stat,
|
|
format: format,
|
|
input: input,
|
|
}, nil
|
|
}
|
|
|
|
// createExtractionHandler creates a handler function for extracting files with progress tracking
|
|
func createExtractionHandler(destDir string, totalFiles int, progressCallback ExtractProgressCallback) func(ctx context.Context, fileInfo archives.FileInfo) error {
|
|
var extractedFiles atomic.Int32
|
|
return func(ctx context.Context, fileInfo archives.FileInfo) error {
|
|
err := extractFile(ctx, fileInfo, destDir)
|
|
if err == nil && !fileInfo.IsDir() {
|
|
extracted := int(extractedFiles.Add(1))
|
|
if progressCallback != nil && totalFiles > 0 {
|
|
progress := int(math.Min(float64((extracted*100)/totalFiles), 100))
|
|
progressCallback(extracted, totalFiles, progress)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
// extractArchive extracts an archive file to a destination directory
|
|
func extractArchive(archivePath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
|
|
// Open the archive file
|
|
info, err := openArchive(archivePath, password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer info.file.Close()
|
|
|
|
// Create destination directory if it doesn't exist
|
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Handle extraction based on format type
|
|
switch f := info.format.(type) {
|
|
case archives.Extractor:
|
|
// For archive formats (zip, rar, 7z, tar, etc.)
|
|
// First, count total files for progress tracking
|
|
totalFiles, err := countArchiveFiles(archivePath, password)
|
|
if err != nil {
|
|
// If counting fails, proceed without progress reporting
|
|
totalFiles = 0
|
|
}
|
|
return f.Extract(context.Background(), info.input, createExtractionHandler(destDir, totalFiles, progressCallback))
|
|
case archives.Decompressor:
|
|
// For single-file compression formats (gz, bz2, xz, etc.)
|
|
// Decompress to a file without the compression extension
|
|
baseName := filepath.Base(archivePath)
|
|
lowerBaseName := strings.ToLower(baseName)
|
|
for _, ext := range supportedArchiveExtensions {
|
|
if strings.HasSuffix(lowerBaseName, ext) {
|
|
// Get the actual suffix from the original filename (preserving case)
|
|
actualSuffix := baseName[len(baseName)-len(ext):]
|
|
baseName = strings.TrimSuffix(baseName, actualSuffix)
|
|
break
|
|
}
|
|
}
|
|
destPath := filepath.Join(destDir, baseName)
|
|
|
|
reader, err := f.OpenReader(info.input)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer reader.Close()
|
|
|
|
destFile, err := os.Create(destPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer destFile.Close()
|
|
|
|
// Report progress at start and end for decompression
|
|
if progressCallback != nil {
|
|
progressCallback(0, 1, 0)
|
|
}
|
|
_, err = io.Copy(destFile, reader)
|
|
if err == nil && progressCallback != nil {
|
|
progressCallback(1, 1, 100)
|
|
}
|
|
return err
|
|
case archives.Archiver:
|
|
// This format is an archiver, try to extract using the extractor interface
|
|
if ext, ok := info.format.(archives.Extractor); ok {
|
|
// Reset file position
|
|
if seeker, ok := info.input.(io.Seeker); ok {
|
|
seeker.Seek(0, io.SeekStart)
|
|
}
|
|
// Count total files for progress tracking
|
|
totalFiles, err := countArchiveFiles(archivePath, password)
|
|
if err != nil {
|
|
totalFiles = 0
|
|
}
|
|
return ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createExtractionHandler(destDir, totalFiles, progressCallback))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// createCountingHandler creates a handler function for counting files in an archive
|
|
func createCountingHandler(count *int) func(ctx context.Context, fileInfo archives.FileInfo) error {
|
|
return func(ctx context.Context, fileInfo archives.FileInfo) error {
|
|
if !fileInfo.IsDir() {
|
|
(*count)++
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// countArchiveFiles counts the number of files in an archive for progress calculation
|
|
func countArchiveFiles(archivePath string, password string) (int, error) {
|
|
info, err := openArchive(archivePath, password)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer info.file.Close()
|
|
|
|
count := 0
|
|
switch f := info.format.(type) {
|
|
case archives.Extractor:
|
|
err = f.Extract(context.Background(), info.input, createCountingHandler(&count))
|
|
case archives.Archiver:
|
|
if ext, ok := info.format.(archives.Extractor); ok {
|
|
if seeker, ok := info.input.(io.Seeker); ok {
|
|
seeker.Seek(0, io.SeekStart)
|
|
}
|
|
err = ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createCountingHandler(&count))
|
|
}
|
|
case archives.Decompressor:
|
|
// Single file compression, count as 1
|
|
return 1, nil
|
|
}
|
|
|
|
return count, err
|
|
}
|
|
|
|
// extractFile handles extracting a single file from an archive
|
|
func extractFile(ctx context.Context, fileInfo archives.FileInfo, destDir string) error {
|
|
cleanPath := filepath.Clean(fileInfo.NameInArchive)
|
|
if strings.HasPrefix(cleanPath, "..") || filepath.IsAbs(cleanPath) {
|
|
return nil
|
|
}
|
|
|
|
if fileInfo.IsDir() {
|
|
return os.MkdirAll(filepath.Join(destDir, cleanPath), fileInfo.Mode())
|
|
}
|
|
|
|
destPath := filepath.Join(destDir, cleanPath)
|
|
|
|
// Create parent directories
|
|
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Open the file from the archive
|
|
reader, err := fileInfo.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer reader.Close()
|
|
|
|
// Create the destination file
|
|
destFile, err := os.Create(destPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer destFile.Close()
|
|
|
|
// Copy the contents
|
|
_, err = io.Copy(destFile, reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Set file permissions
|
|
return os.Chmod(destPath, fileInfo.Mode())
|
|
}
|
|
|
|
// isMultiPartArchive checks if a file is part of a multi-part archive
|
|
func isMultiPartArchive(filename string) bool {
|
|
info := getArchivePartInfo(filename)
|
|
return info.IsMultiPart
|
|
}
|
|
|
|
// getArchivePartInfo returns detailed information about a multi-part archive file
|
|
func getArchivePartInfo(filename string) ArchivePartInfo {
|
|
baseName := filepath.Base(filename)
|
|
|
|
for _, pattern := range multiPartArchivePatterns {
|
|
matches := pattern.FindStringSubmatch(baseName)
|
|
if len(matches) >= 3 {
|
|
partNum := 0
|
|
_, err := parsePartNumber(matches[2], &partNum)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
info := ArchivePartInfo{
|
|
IsMultiPart: true,
|
|
BaseName: matches[1],
|
|
PartNumber: partNum,
|
|
Pattern: pattern.String(),
|
|
}
|
|
|
|
// Determine the first part path based on pattern
|
|
dir := filepath.Dir(filename)
|
|
info.FirstPartPath = determineFirstPartPath(dir, info.BaseName, pattern.String())
|
|
|
|
return info
|
|
}
|
|
}
|
|
|
|
// Check for .rar files that might be the first part of old-style multi-part RAR
|
|
if strings.HasSuffix(strings.ToLower(baseName), ".rar") && !strings.Contains(strings.ToLower(baseName), ".part") {
|
|
// Check if there are .r00, .r01 files in the same directory
|
|
dir := filepath.Dir(filename)
|
|
nameWithoutExt := strings.TrimSuffix(baseName, filepath.Ext(baseName))
|
|
r00Path := filepath.Join(dir, nameWithoutExt+".r00")
|
|
if _, err := os.Stat(r00Path); err == nil {
|
|
return ArchivePartInfo{
|
|
IsMultiPart: true,
|
|
BaseName: nameWithoutExt,
|
|
PartNumber: 1, // .rar is the first part in old-style
|
|
FirstPartPath: filename,
|
|
Pattern: "rar-old-style",
|
|
}
|
|
}
|
|
}
|
|
|
|
return ArchivePartInfo{IsMultiPart: false}
|
|
}
|
|
|
|
// parsePartNumber parses a part number string and stores it in the provided pointer
|
|
// Returns the parsed number and nil error on success
|
|
func parsePartNumber(s string, partNum *int) (int, error) {
|
|
n := 0
|
|
for _, c := range s {
|
|
if c >= '0' && c <= '9' {
|
|
n = n*10 + int(c-'0')
|
|
}
|
|
}
|
|
// For .001, .002 style, part number is the value itself
|
|
// For .part01 style, part number is the value itself
|
|
// For .r00, .r01 style: r00=2 (since .rar is part 1), r01=3, etc.
|
|
// However, for consistency, we return the raw number and handle the offset elsewhere
|
|
*partNum = n
|
|
// Treat 00 as part 0, let callers handle the semantics
|
|
if n == 0 {
|
|
*partNum = 1 // For .001 format, 001 should be 1
|
|
}
|
|
return *partNum, nil
|
|
}
|
|
|
|
// determineFirstPartPath determines the path to the first part of a multi-part archive
|
|
func determineFirstPartPath(dir, baseName, pattern string) string {
|
|
switch {
|
|
case strings.Contains(pattern, `.7z)`):
|
|
// 7z multi-part: first part is .7z.001
|
|
return filepath.Join(dir, baseName+".001")
|
|
case strings.Contains(pattern, `.part`):
|
|
// RAR new style: first part is .part01.rar or .part1.rar
|
|
// Try both single and double digit formats
|
|
if _, err := os.Stat(filepath.Join(dir, baseName+".part1.rar")); err == nil {
|
|
return filepath.Join(dir, baseName+".part1.rar")
|
|
}
|
|
return filepath.Join(dir, baseName+".part01.rar")
|
|
case strings.Contains(pattern, `.r(`):
|
|
// RAR old style: first part is .rar (not .r00)
|
|
return filepath.Join(dir, baseName+".rar")
|
|
case strings.Contains(pattern, `.zip)`):
|
|
// ZIP multi-part: first part is .zip.001
|
|
return filepath.Join(dir, baseName+".001")
|
|
case strings.Contains(pattern, `.z(`):
|
|
// ZIP split: last part is .zip, but extraction should start from .z01
|
|
return filepath.Join(dir, baseName+".z01")
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// isFirstPart checks if the given file is the first part of a multi-part archive
|
|
func isFirstPart(filename string) bool {
|
|
info := getArchivePartInfo(filename)
|
|
if !info.IsMultiPart {
|
|
return false
|
|
}
|
|
|
|
// For most formats, part 1 is the first part
|
|
// For old-style RAR, check if this is the .rar file
|
|
if info.Pattern == "rar-old-style" {
|
|
return strings.HasSuffix(strings.ToLower(filename), ".rar")
|
|
}
|
|
|
|
return info.PartNumber == 1
|
|
}
|
|
|
|
// GetMultiPartArchiveBaseName returns the base name for a multi-part archive
|
|
// This is used to group related parts together
|
|
func GetMultiPartArchiveBaseName(filename string) string {
|
|
info := getArchivePartInfo(filename)
|
|
if !info.IsMultiPart {
|
|
return ""
|
|
}
|
|
return filepath.Join(filepath.Dir(filename), info.BaseName)
|
|
}
|
|
|
|
// extractMultiPartArchive extracts a multi-part archive starting from the first part
|
|
func extractMultiPartArchive(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
|
|
info := getArchivePartInfo(firstPartPath)
|
|
|
|
// For 7z multi-part archives, the bodgit/sevenzip library handles multi-volume automatically
|
|
// when using OpenReader with a .001 file
|
|
if strings.Contains(info.Pattern, `\.7z\)`) || strings.HasSuffix(strings.ToLower(firstPartPath), ".7z.001") {
|
|
return extractSevenZipMultiPart(firstPartPath, destDir, password, progressCallback)
|
|
}
|
|
|
|
// For RAR multi-part archives, use the archives library with Name and FS fields
|
|
if strings.Contains(info.Pattern, "rar") || info.Pattern == "rar-old-style" {
|
|
return extractRarMultiPart(firstPartPath, destDir, password, progressCallback)
|
|
}
|
|
|
|
// For ZIP multi-part archives (.zip.001, .zip.002, etc.), concatenate parts and extract
|
|
if strings.Contains(info.Pattern, `\.zip\)`) || strings.HasSuffix(strings.ToLower(firstPartPath), ".zip.001") {
|
|
return extractZipMultiPart(firstPartPath, destDir, password, progressCallback)
|
|
}
|
|
|
|
// For other formats, try standard extraction (may not work for all multi-part formats)
|
|
return extractArchive(firstPartPath, destDir, password, progressCallback)
|
|
}
|