package scoutpkg import ( "archive/zip" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path" "path/filepath" "regexp" "runtime" "sort" "strings" ) const ManifestName = "scout-package.json" var ( namePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) versionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$`) digestPattern = regexp.MustCompile(`^[a-fA-F0-9]{64}$`) ) type Manifest struct { SchemaVersion int `json:"schema_version"` Name string `json:"name"` Version string `json:"version"` Description string `json:"description,omitempty"` Channel string `json:"channel,omitempty"` PackageType string `json:"package_type,omitempty"` Targets []Target `json:"targets"` } type Target struct { ID string `json:"id,omitempty"` Platform string `json:"platform"` Architecture string `json:"architecture"` Path string `json:"path"` Entrypoint string `json:"entrypoint,omitempty"` PackageType string `json:"package_type,omitempty"` UpgradeFrom []string `json:"upgrade_from,omitempty"` SHA256 string `json:"sha256,omitempty"` } type Archive struct { Manifest Manifest reader *zip.ReadCloser files []*zip.File } func Open(fileName string) (*Archive, error) { reader, err := zip.OpenReader(fileName) if err != nil { return nil, fmt.Errorf("打开 Scout 包失败: %w", err) } manifest, err := readManifest(reader.File) if err != nil { reader.Close() return nil, err } return &Archive{Manifest: manifest, reader: reader, files: reader.File}, nil } func Inspect(reader io.ReaderAt, size int64) (Manifest, error) { archive, err := zip.NewReader(reader, size) if err != nil { return Manifest{}, fmt.Errorf("读取 Scout 包失败: %w", err) } return readManifest(archive.File) } func (archive *Archive) Close() error { if archive == nil || archive.reader == nil { return nil } return archive.reader.Close() } func (archive *Archive) Match(platform, architecture string) (Target, bool) { return MatchTarget(archive.Manifest.Targets, platform, architecture) } func (archive *Archive) Extract(platform, architecture, destination string) (Target, error) { target, ok := archive.Match(platform, architecture) if !ok { return Target{}, errors.New("Scout 包中没有兼容当前平台和架构的目标") } if strings.TrimSpace(destination) == "" { return Target{}, errors.New("解压目录不能为空") } destination, err := filepath.Abs(destination) if err != nil { return Target{}, fmt.Errorf("解析解压目录失败: %w", err) } if err := os.MkdirAll(destination, 0o755); err != nil { return Target{}, fmt.Errorf("创建解压目录失败: %w", err) } matched := false singleFile := false for _, file := range archive.files { relative, included := targetRelativePath(file.Name, target.Path) if !included { continue } matched = true if relative == "" && !file.FileInfo().IsDir() { relative = path.Base(file.Name) singleFile = true } if relative == "" { continue } outputPath, err := safeOutputPath(destination, relative) if err != nil { return Target{}, err } if file.FileInfo().IsDir() { if err := os.MkdirAll(outputPath, 0o755); err != nil { return Target{}, fmt.Errorf("创建包目录失败: %w", err) } continue } if file.Mode()&os.ModeSymlink != 0 { return Target{}, fmt.Errorf("Scout 包不允许包含符号链接: %s", file.Name) } if err := extractFile(file, outputPath); err != nil { return Target{}, err } } if !matched { return Target{}, fmt.Errorf("Scout 包目标路径不存在: %s", target.Path) } if target.SHA256 != "" && singleFile { digest, err := FileSHA256(filepath.Join(destination, path.Base(target.Path))) if err != nil { return Target{}, err } if !strings.EqualFold(digest, target.SHA256) { return Target{}, errors.New("Scout 包目标文件 SHA-256 校验失败") } } return target, nil } func NormalizePlatform(value string) (string, error) { value = strings.ToLower(strings.TrimSpace(value)) switch value { case "darwin", "osx", "mac": value = "macos" case "win", "win32", "win64": value = "windows" case "*", "all", "universal": value = "any" } switch value { case "windows", "macos", "linux", "android", "any": return value, nil default: return "", fmt.Errorf("不支持的平台: %s", value) } } func NormalizeArchitecture(value string) (string, error) { value = strings.ToLower(strings.TrimSpace(value)) switch value { case "x86_64", "x64": value = "amd64" case "aarch64", "arm64-v8a": value = "arm64" case "x86", "i386", "i686": value = "386" case "armeabi-v7a", "arm", "arm32": value = "armv7" case "*", "all", "any": value = "universal" } switch value { case "amd64", "arm64", "386", "armv7", "universal": return value, nil default: return "", fmt.Errorf("不支持的架构: %s", value) } } func CurrentTarget() (string, string) { platform, err := NormalizePlatform(runtime.GOOS) if err != nil { platform = runtime.GOOS } architecture, err := NormalizeArchitecture(runtime.GOARCH) if err != nil { architecture = runtime.GOARCH } return platform, architecture } func MatchTarget(targets []Target, platform, architecture string) (Target, bool) { normalizedPlatform, err := NormalizePlatform(platform) if err != nil { return Target{}, false } normalizedArchitecture, err := NormalizeArchitecture(architecture) if err != nil { return Target{}, false } type candidate struct { target Target score int } candidates := make([]candidate, 0, len(targets)) for _, target := range targets { targetPlatform, platformErr := NormalizePlatform(target.Platform) targetArchitecture, architectureErr := NormalizeArchitecture(target.Architecture) if platformErr != nil || architectureErr != nil { continue } score := matchScore(targetPlatform, targetArchitecture, normalizedPlatform, normalizedArchitecture) if score > 0 { target.Platform = targetPlatform target.Architecture = targetArchitecture candidates = append(candidates, candidate{target: target, score: score}) } } if len(candidates) == 0 { return Target{}, false } sort.SliceStable(candidates, func(left, right int) bool { return candidates[left].score > candidates[right].score }) return candidates[0].target, true } func ValidateManifest(manifest *Manifest) error { if manifest == nil { return errors.New("Scout 包清单不能为空") } if manifest.SchemaVersion == 0 { manifest.SchemaVersion = 1 } if manifest.SchemaVersion != 1 { return fmt.Errorf("不支持的 Scout 包清单版本: %d", manifest.SchemaVersion) } manifest.Name = strings.TrimSpace(manifest.Name) manifest.Version = strings.TrimSpace(manifest.Version) if !namePattern.MatchString(manifest.Name) { return errors.New("Scout 包清单中的包名无效") } if !versionPattern.MatchString(manifest.Version) { return errors.New("Scout 包清单中的版本无效") } manifest.Channel = strings.ToLower(strings.TrimSpace(manifest.Channel)) if manifest.Channel == "" { manifest.Channel = "stable" } if manifest.Channel != "stable" && manifest.Channel != "beta" && manifest.Channel != "dev" { return errors.New("发布通道只能是 stable、beta 或 dev") } manifest.PackageType = normalizePackageType(manifest.PackageType) if manifest.PackageType == "" { return errors.New("包类型只能是 full 或 delta") } if len(manifest.Targets) == 0 { return errors.New("Scout 包清单至少需要一个目标") } duplicates := make(map[string]struct{}, len(manifest.Targets)) for index := range manifest.Targets { target := &manifest.Targets[index] platform, err := NormalizePlatform(target.Platform) if err != nil { return err } architecture, err := NormalizeArchitecture(target.Architecture) if err != nil { return err } target.Platform = platform target.Architecture = architecture target.Path, err = cleanArchivePath(target.Path) if err != nil { return fmt.Errorf("目标路径无效: %w", err) } if target.Entrypoint != "" { target.Entrypoint, err = cleanArchivePath(target.Entrypoint) if err != nil { return fmt.Errorf("启动入口无效: %w", err) } if !pathWithinTarget(target.Entrypoint, target.Path) { return errors.New("启动入口必须位于目标路径中") } } target.PackageType = normalizePackageType(target.PackageType) if target.PackageType == "" { target.PackageType = manifest.PackageType } if target.PackageType != "full" && target.PackageType != "delta" { return errors.New("目标包类型只能是 full 或 delta") } if target.PackageType == "delta" && len(target.UpgradeFrom) == 0 { return errors.New("差分包必须声明 upgrade_from") } if target.SHA256 != "" && !digestPattern.MatchString(target.SHA256) { return errors.New("目标 SHA-256 格式无效") } key := strings.Join([]string{target.Platform, target.Architecture, target.PackageType, strings.Join(target.UpgradeFrom, ",")}, "|") if _, exists := duplicates[key]; exists { return fmt.Errorf("Scout 包清单包含重复目标: %s/%s", target.Platform, target.Architecture) } duplicates[key] = struct{}{} } return nil } func FileSHA256(fileName string) (string, error) { file, err := os.Open(fileName) if err != nil { return "", fmt.Errorf("打开文件失败: %w", err) } defer file.Close() hash := sha256.New() if _, err := io.Copy(hash, file); err != nil { return "", fmt.Errorf("读取文件失败: %w", err) } return hex.EncodeToString(hash.Sum(nil)), nil } func readManifest(files []*zip.File) (Manifest, error) { var manifestFile *zip.File for _, file := range files { if _, err := cleanArchivePath(file.Name); err != nil { return Manifest{}, fmt.Errorf("Scout 包包含不安全路径 %q: %w", file.Name, err) } if file.Mode()&os.ModeSymlink != 0 { return Manifest{}, fmt.Errorf("Scout 包不允许包含符号链接: %s", file.Name) } if file.Name == ManifestName { manifestFile = file } } if manifestFile == nil { return Manifest{}, fmt.Errorf("Scout 包缺少 %s", ManifestName) } reader, err := manifestFile.Open() if err != nil { return Manifest{}, fmt.Errorf("打开 Scout 包清单失败: %w", err) } defer reader.Close() decoder := json.NewDecoder(io.LimitReader(reader, 1<<20)) decoder.DisallowUnknownFields() var manifest Manifest if err := decoder.Decode(&manifest); err != nil { return Manifest{}, fmt.Errorf("解析 Scout 包清单失败: %w", err) } if err := ValidateManifest(&manifest); err != nil { return Manifest{}, err } for _, target := range manifest.Targets { found := false for _, file := range files { if _, included := targetRelativePath(file.Name, target.Path); included { found = true break } } if !found { return Manifest{}, fmt.Errorf("Scout 包目标路径不存在: %s", target.Path) } } return manifest, nil } func normalizePackageType(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { return "full" } if value == "complete" { return "full" } return value } func matchScore(targetPlatform, targetArchitecture, platform, architecture string) int { switch { case targetPlatform == platform && targetArchitecture == architecture: return 400 case targetPlatform == platform && targetArchitecture == "universal": return 300 case targetPlatform == "any" && targetArchitecture == architecture: return 200 case targetPlatform == "any" && targetArchitecture == "universal": return 100 default: return 0 } } func cleanArchivePath(value string) (string, error) { value = strings.TrimSpace(value) if value == "" { return "", errors.New("路径不能为空") } if strings.Contains(value, "\\") || strings.ContainsRune(value, '\x00') || strings.HasPrefix(value, "/") { return "", errors.New("路径必须是安全的相对路径") } cleaned := path.Clean(value) if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") || path.IsAbs(cleaned) { return "", errors.New("路径必须位于 Scout 包内部") } return strings.TrimSuffix(cleaned, "/"), nil } func pathWithinTarget(candidate, target string) bool { return candidate == target || strings.HasPrefix(candidate, target+"/") } func targetRelativePath(fileName, targetPath string) (string, bool) { cleaned := strings.TrimSuffix(path.Clean(fileName), "/") if cleaned == targetPath { return "", true } prefix := targetPath + "/" if strings.HasPrefix(cleaned, prefix) { return strings.TrimPrefix(cleaned, prefix), true } return "", false } func safeOutputPath(destination, relative string) (string, error) { normalized, err := cleanArchivePath(relative) if err != nil { return "", err } output := filepath.Join(destination, filepath.FromSlash(normalized)) relation, err := filepath.Rel(destination, output) if err != nil || relation == ".." || strings.HasPrefix(relation, ".."+string(filepath.Separator)) { return "", errors.New("Scout 包文件试图逃逸解压目录") } return output, nil } func extractFile(file *zip.File, outputPath string) error { if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { return fmt.Errorf("创建包文件目录失败: %w", err) } input, err := file.Open() if err != nil { return fmt.Errorf("读取包文件失败: %w", err) } defer input.Close() mode := file.Mode().Perm() if mode == 0 { mode = 0o644 } output, err := os.OpenFile(outputPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) if err != nil { return fmt.Errorf("创建解压文件失败: %w", err) } _, copyErr := io.Copy(output, input) closeErr := output.Close() if copyErr != nil { return fmt.Errorf("解压包文件失败: %w", copyErr) } if closeErr != nil { return fmt.Errorf("关闭解压文件失败: %w", closeErr) } return nil }