474 lines
14 KiB
Go
474 lines
14 KiB
Go
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
|
|
}
|