Scout/pkg/scoutpkg/manifest.go
2026-08-31 15:34:36 +08:00

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
}