2026-08-17 10:10:14 +08:00
|
|
|
|
// Package updatepackage 负责解析 deploy 当前产出的重打包 ZIP 更新包格式,
|
|
|
|
|
|
// 并从中提取、校验原生后端工件。包内同时支持两种来源:通过
|
|
|
|
|
|
// artifact-selection.json 清单声明的后端原生工件(backend_native.go),以及
|
|
|
|
|
|
// 由 Jenkins 或运维人员直接提供的 JAR 文件(direct_native.go)。包边界仅限
|
|
|
|
|
|
// 更新包的解析、结构校验与工件落地,不涉及具体部署流程。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
package updatepackage
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"archive/zip"
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"os"
|
|
|
|
|
|
"path"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
|
|
"yms-daemon/internal/filestore"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// manifestName 更新包根目录下描述工件选择结果的清单文件名。
|
|
|
|
|
|
manifestName = "artifact-selection.json"
|
|
|
|
|
|
// backendArtifactKind 表示清单中后端工件的类别,取值须为 "BACKEND"。
|
|
|
|
|
|
backendArtifactKind = "BACKEND"
|
|
|
|
|
|
// nativeArtifactType 表示原生工件类型,type 与 selectedType 均须为 "native"。
|
|
|
|
|
|
nativeArtifactType = "native"
|
|
|
|
|
|
// maximumManifestBytes 清单文件允许的最大未压缩字节数,用于防止恶意超大清单。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
maximumManifestBytes = 1 << 20
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// BackendNativePackage 一个已打开的、恰好包含一个原生后端工件的重打包 ZIP 更新包。
|
|
|
|
|
|
// 它封装了底层 ZIP 句柄与被选中工件对应的条目,并通过导出字段暴露解析出的元数据。
|
|
|
|
|
|
// 不变量:该包在被选中后始终只对应一个明确的原生后端工件;Close 之后不得再使用。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
type BackendNativePackage struct {
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// archive 底层打开的 ZIP 句柄,Close 时释放。
|
|
|
|
|
|
archive *zip.ReadCloser
|
|
|
|
|
|
// artifactEntry 被选中原生后端工件在 ZIP 中对应的条目。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
artifactEntry *zip.File
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// PackagePath 更新包文件的绝对路径。
|
|
|
|
|
|
PackagePath string
|
|
|
|
|
|
// PackageSHA256 整个更新包文件的 SHA-256 摘要(十六进制)。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
PackageSHA256 string
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// CustomerCode 从清单中解析出的客户编码。
|
|
|
|
|
|
CustomerCode string
|
|
|
|
|
|
// VersionID 从清单中解析出的版本标识。
|
|
|
|
|
|
VersionID string
|
|
|
|
|
|
// ArtifactID 被选中工件在清单中声明的 ID。
|
|
|
|
|
|
ArtifactID int64
|
|
|
|
|
|
// FileName 被选中工件在 ZIP 根目录下的文件名。
|
|
|
|
|
|
FileName string
|
|
|
|
|
|
// Identity 被选中工件的不可变身份(未压缩大小与 SHA-256)。
|
|
|
|
|
|
Identity filestore.Identity
|
2026-08-16 01:27:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// OpenBackendNative 校验更新包文件路径、计算包摘要并解析 ZIP 结构,
|
|
|
|
|
|
// 从 artifact-selection.json 清单中选出恰好一个显式声明的原生后端工件,
|
|
|
|
|
|
// 返回已打开的 BackendNativePackage。
|
|
|
|
|
|
// packagePath 必须是干净绝对路径且为直接普通文件;校验失败时返回错误。
|
|
|
|
|
|
// 成功返回后,调用方须通过 Close 释放底层 ZIP 句柄。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func OpenBackendNative(packagePath string) (*BackendNativePackage, error) {
|
|
|
|
|
|
if err := validateAbsoluteRegularFile(packagePath, "update package"); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
packageDigest, err := hashFile(packagePath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
archive, err := zip.OpenReader(packagePath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("open update package ZIP %s: %w", packagePath, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
closeOnError := func(cause error) (*BackendNativePackage, error) {
|
|
|
|
|
|
return nil, errors.Join(cause, archive.Close())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
entries, err := validateEntries(archive.File)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return closeOnError(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
manifestEntry, found := entries[manifestName]
|
|
|
|
|
|
if !found {
|
|
|
|
|
|
return closeOnError(errors.New("update package is missing artifact-selection.json"))
|
|
|
|
|
|
}
|
|
|
|
|
|
manifest, err := decodeManifest(manifestEntry)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return closeOnError(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
selected, err := selectNativeBackend(manifest.BackendArtifacts)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return closeOnError(err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := validateRootFileName(selected.FileName); err != nil {
|
|
|
|
|
|
return closeOnError(fmt.Errorf("invalid backendArtifacts.fileName: %w", err))
|
|
|
|
|
|
}
|
|
|
|
|
|
artifactEntry, found := entries[selected.FileName]
|
|
|
|
|
|
if !found {
|
|
|
|
|
|
return closeOnError(fmt.Errorf("native backend artifact declared by backendArtifacts.fileName is missing: %s", selected.FileName))
|
|
|
|
|
|
}
|
|
|
|
|
|
if artifactEntry.FileInfo().IsDir() {
|
|
|
|
|
|
return closeOnError(fmt.Errorf("native backend artifact is not a file: %s", selected.FileName))
|
|
|
|
|
|
}
|
|
|
|
|
|
identity := filestore.Identity{
|
|
|
|
|
|
Size: int64(artifactEntry.UncompressedSize64),
|
|
|
|
|
|
SHA256: selected.SHA256,
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := identity.Validate(); err != nil {
|
|
|
|
|
|
return closeOnError(fmt.Errorf("invalid backendArtifacts.sha256: %w", err))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return &BackendNativePackage{
|
|
|
|
|
|
archive: archive,
|
|
|
|
|
|
artifactEntry: artifactEntry,
|
|
|
|
|
|
PackagePath: packagePath,
|
|
|
|
|
|
PackageSHA256: packageDigest,
|
|
|
|
|
|
CustomerCode: manifest.CustomerCode,
|
|
|
|
|
|
VersionID: manifest.VersionID,
|
|
|
|
|
|
ArtifactID: selected.ID,
|
|
|
|
|
|
FileName: selected.FileName,
|
|
|
|
|
|
Identity: identity,
|
|
|
|
|
|
}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// Close 释放底层 ZIP 文件句柄并清空内部条目引用。对 nil 接收者或已关闭的包,
|
|
|
|
|
|
// 重复调用幂等返回 nil。关闭后包不再可用。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func (p *BackendNativePackage) Close() error {
|
|
|
|
|
|
if p == nil || p.archive == nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
archive := p.archive
|
|
|
|
|
|
p.archive = nil
|
|
|
|
|
|
p.artifactEntry = nil
|
|
|
|
|
|
return archive.Close()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// ExtractArtifact 将被选中的原生后端 JAR 写入 destination,并在发布前校验
|
|
|
|
|
|
// 未压缩大小与 SHA-256 摘要,两者均匹配后才原子重命名到位。
|
|
|
|
|
|
// destination 必须是干净绝对路径;其父目录会被创建且须为直接目录(非符号链接)。
|
|
|
|
|
|
// 过程先写入临时文件、同步后重命名,再同步父目录;任何一步失败都会清理临时文件,
|
|
|
|
|
|
// 保证 destination 不会残留半成品。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func (p *BackendNativePackage) ExtractArtifact(destination string) error {
|
|
|
|
|
|
if p == nil || p.archive == nil || p.artifactEntry == nil {
|
|
|
|
|
|
return errors.New("native backend update package is not open")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !filepath.IsAbs(destination) || filepath.Clean(destination) != destination {
|
|
|
|
|
|
return errors.New("native backend extraction destination must be a clean absolute path")
|
|
|
|
|
|
}
|
|
|
|
|
|
parent := filepath.Dir(destination)
|
|
|
|
|
|
if err := os.MkdirAll(parent, 0o750); err != nil {
|
|
|
|
|
|
return fmt.Errorf("create native backend extraction directory: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
parentInfo, err := os.Lstat(parent)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("inspect native backend extraction directory: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !parentInfo.IsDir() || parentInfo.Mode()&os.ModeSymlink != 0 {
|
|
|
|
|
|
return fmt.Errorf("native backend extraction parent is not a direct directory: %s", parent)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
source, err := p.artifactEntry.Open()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("open native backend artifact %s: %w", p.FileName, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
temporary, err := os.CreateTemp(parent, ".backend-jar-*")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
_ = source.Close()
|
|
|
|
|
|
return fmt.Errorf("create native backend extraction file: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
temporaryPath := temporary.Name()
|
|
|
|
|
|
defer os.Remove(temporaryPath)
|
|
|
|
|
|
if err := temporary.Chmod(0o640); err != nil {
|
|
|
|
|
|
_ = source.Close()
|
|
|
|
|
|
_ = temporary.Close()
|
|
|
|
|
|
return fmt.Errorf("set native backend extraction permissions: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
digest := sha256.New()
|
|
|
|
|
|
written, copyErr := io.Copy(io.MultiWriter(temporary, digest), source)
|
|
|
|
|
|
closeSourceErr := source.Close()
|
|
|
|
|
|
if err := errors.Join(copyErr, closeSourceErr); err != nil {
|
|
|
|
|
|
_ = temporary.Close()
|
|
|
|
|
|
return fmt.Errorf("extract native backend artifact: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if written != p.Identity.Size {
|
|
|
|
|
|
_ = temporary.Close()
|
|
|
|
|
|
return fmt.Errorf("native backend artifact size mismatch: got %d, want %d", written, p.Identity.Size)
|
|
|
|
|
|
}
|
|
|
|
|
|
actualDigest := hex.EncodeToString(digest.Sum(nil))
|
|
|
|
|
|
if !strings.EqualFold(actualDigest, p.Identity.SHA256) {
|
|
|
|
|
|
_ = temporary.Close()
|
|
|
|
|
|
return fmt.Errorf("native backend artifact SHA-256 mismatch: got %s, want %s", actualDigest, p.Identity.SHA256)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := temporary.Sync(); err != nil {
|
|
|
|
|
|
_ = temporary.Close()
|
|
|
|
|
|
return fmt.Errorf("flush native backend extraction file: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := temporary.Close(); err != nil {
|
|
|
|
|
|
return fmt.Errorf("close native backend extraction file: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := os.Rename(temporaryPath, destination); err != nil {
|
|
|
|
|
|
return fmt.Errorf("publish native backend extraction file: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return syncDirectory(parent)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// artifactSelectionManifest artifact-selection.json 清单的结构化表示。
|
|
|
|
|
|
// 字段标签严格对应 deploy 产出的 JSON 键名,不允许未知字段。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
type artifactSelectionManifest struct {
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// CustomerCode 客户编码,解析后不得为空。
|
|
|
|
|
|
CustomerCode string `json:"customerCode"`
|
|
|
|
|
|
// CustomerDisplayName 客户展示名,允许为空。
|
|
|
|
|
|
CustomerDisplayName *string `json:"customerDisplayName"`
|
|
|
|
|
|
// VersionID 版本标识,解析后不得为空。
|
|
|
|
|
|
VersionID string `json:"versionId"`
|
|
|
|
|
|
// Items 随包附带的脚本列表。
|
|
|
|
|
|
Items []string `json:"items"`
|
|
|
|
|
|
// BackendArtifacts 后端工件列表,其中须恰好一个被选为原生后端。
|
|
|
|
|
|
BackendArtifacts []manifestArtifact `json:"backendArtifacts"`
|
|
|
|
|
|
// FrontendArtifacts 前端工件列表,本包解析但不使用。
|
|
|
|
|
|
FrontendArtifacts []manifestArtifact `json:"frontendArtifacts"`
|
|
|
|
|
|
// NodeSSRArtifacts Node SSR 工件列表,本包解析但不使用。
|
|
|
|
|
|
NodeSSRArtifacts []manifestArtifact `json:"nodeSsrArtifacts"`
|
|
|
|
|
|
// Remark 备注信息,允许为空。
|
|
|
|
|
|
Remark *string `json:"remark"`
|
2026-08-16 01:27:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// manifestArtifact 清单中单个工件的结构化表示。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
type manifestArtifact struct {
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// ID 工件唯一标识,被选中的原生后端须大于 0。
|
|
|
|
|
|
ID int64 `json:"id"`
|
|
|
|
|
|
// VersionCode 工件版本编码,允许为空。
|
|
|
|
|
|
VersionCode *string `json:"versionCode"`
|
|
|
|
|
|
// ArtifactKind 工件类别,后端工件须为 "BACKEND"。
|
|
|
|
|
|
ArtifactKind string `json:"artifactKind"`
|
|
|
|
|
|
// Type 工件类型,原生工件须为 "native"。
|
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
|
// SelectedType 被选中的类型,原生工件须为 "native"。
|
|
|
|
|
|
SelectedType string `json:"selectedType"`
|
|
|
|
|
|
// Platform 目标平台,允许为空。
|
|
|
|
|
|
Platform *string `json:"platform"`
|
|
|
|
|
|
// FileName 工件在 ZIP 根目录下的文件名。
|
|
|
|
|
|
FileName string `json:"fileName"`
|
|
|
|
|
|
// FilePath 工件在归档中的路径,允许为空。
|
|
|
|
|
|
FilePath *string `json:"filePath"`
|
|
|
|
|
|
// SHA256 工件的十六进制 SHA-256 摘要。
|
|
|
|
|
|
SHA256 string `json:"sha256"`
|
|
|
|
|
|
// ImageRef 镜像引用,允许为空。
|
|
|
|
|
|
ImageRef *string `json:"imageRef"`
|
2026-08-16 01:27:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// decodeManifest 读取并解析 ZIP 中名为 artifact-selection.json 的清单条目。
|
|
|
|
|
|
// entry 必须是普通文件且不超过 maximumManifestBytes;解析前先做精确键名校验,
|
|
|
|
|
|
// 再禁用未知字段进行解码,最后校验 customerCode 与 versionId 均非空。
|
|
|
|
|
|
// 任何一步失败都会返回描述性的错误。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func decodeManifest(entry *zip.File) (artifactSelectionManifest, error) {
|
|
|
|
|
|
if entry.FileInfo().IsDir() {
|
|
|
|
|
|
return artifactSelectionManifest{}, errors.New("artifact-selection.json is not a file")
|
|
|
|
|
|
}
|
|
|
|
|
|
if entry.UncompressedSize64 > maximumManifestBytes {
|
|
|
|
|
|
return artifactSelectionManifest{}, errors.New("artifact-selection.json exceeds size limit")
|
|
|
|
|
|
}
|
|
|
|
|
|
reader, err := entry.Open()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return artifactSelectionManifest{}, fmt.Errorf("open artifact-selection.json: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer reader.Close()
|
|
|
|
|
|
|
|
|
|
|
|
document, err := io.ReadAll(io.LimitReader(reader, maximumManifestBytes+1))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return artifactSelectionManifest{}, fmt.Errorf("read artifact-selection.json: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(document) > maximumManifestBytes {
|
|
|
|
|
|
return artifactSelectionManifest{}, errors.New("artifact-selection.json exceeds size limit")
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := validateExactManifestJSON(document); err != nil {
|
|
|
|
|
|
return artifactSelectionManifest{}, fmt.Errorf("validate artifact-selection.json keys: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var manifest artifactSelectionManifest
|
|
|
|
|
|
decoder := json.NewDecoder(bytes.NewReader(document))
|
|
|
|
|
|
decoder.DisallowUnknownFields()
|
|
|
|
|
|
if err := decoder.Decode(&manifest); err != nil {
|
|
|
|
|
|
return artifactSelectionManifest{}, fmt.Errorf("decode artifact-selection.json: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := ensureJSONEnd(decoder); err != nil {
|
|
|
|
|
|
return artifactSelectionManifest{}, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.TrimSpace(manifest.CustomerCode) == "" || strings.TrimSpace(manifest.VersionID) == "" {
|
|
|
|
|
|
return artifactSelectionManifest{}, errors.New("artifact-selection.json customerCode and versionId are required")
|
|
|
|
|
|
}
|
|
|
|
|
|
return manifest, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// rootManifestKeys 清单根对象允许出现的所有 JSON 键名集合,
|
|
|
|
|
|
// 用于精确校验,拒绝任何未知键(包括大小写不匹配的键)。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
var rootManifestKeys = map[string]struct{}{
|
|
|
|
|
|
"customerCode": {},
|
|
|
|
|
|
"customerDisplayName": {},
|
|
|
|
|
|
"versionId": {},
|
|
|
|
|
|
"items": {},
|
|
|
|
|
|
"backendArtifacts": {},
|
|
|
|
|
|
"frontendArtifacts": {},
|
|
|
|
|
|
"nodeSsrArtifacts": {},
|
|
|
|
|
|
"remark": {},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// artifactManifestKeys 单个工件对象允许出现的所有 JSON 键名集合。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
var artifactManifestKeys = map[string]struct{}{
|
|
|
|
|
|
"id": {},
|
|
|
|
|
|
"versionCode": {},
|
|
|
|
|
|
"artifactKind": {},
|
|
|
|
|
|
"type": {},
|
|
|
|
|
|
"selectedType": {},
|
|
|
|
|
|
"platform": {},
|
|
|
|
|
|
"fileName": {},
|
|
|
|
|
|
"filePath": {},
|
|
|
|
|
|
"sha256": {},
|
|
|
|
|
|
"imageRef": {},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// validateExactManifestJSON 使用 json.Decoder 对清单文档做递归精确校验:
|
|
|
|
|
|
// 根对象与各工件对象的键名必须落在预定义白名单内,且不允许重复键与未知键。
|
|
|
|
|
|
// document 必须是单一 JSON 值,其后不得有额外内容。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func validateExactManifestJSON(document []byte) error {
|
|
|
|
|
|
decoder := json.NewDecoder(bytes.NewReader(document))
|
|
|
|
|
|
decoder.UseNumber()
|
|
|
|
|
|
if err := validateObject(decoder, "", rootManifestKeys, true); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
return ensureJSONEnd(decoder)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// validateObject 校验下一个 JSON 值为对象:确认分隔符为 '{',逐键检查重复与
|
|
|
|
|
|
// 未知字段;root 为 true 时,对三个工件列表字段递归调用 validateArtifactArray,
|
|
|
|
|
|
// 其余字段则通过 consumeJSONValue 仅做语法与重复键校验。objectPath 用于构造
|
|
|
|
|
|
// 错误信息中的字段路径。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func validateObject(decoder *json.Decoder, objectPath string, allowed map[string]struct{}, root bool) error {
|
|
|
|
|
|
token, err := decoder.Token()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
|
|
|
|
|
|
return fmt.Errorf("%s must be a JSON object", displayJSONPath(objectPath))
|
|
|
|
|
|
}
|
|
|
|
|
|
seen := make(map[string]struct{}, len(allowed))
|
|
|
|
|
|
for decoder.More() {
|
|
|
|
|
|
keyToken, err := decoder.Token()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
key, ok := keyToken.(string)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return fmt.Errorf("%s contains a non-string key", displayJSONPath(objectPath))
|
|
|
|
|
|
}
|
|
|
|
|
|
fieldPath := key
|
|
|
|
|
|
if objectPath != "" {
|
|
|
|
|
|
fieldPath = objectPath + "." + key
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, duplicate := seen[key]; duplicate {
|
|
|
|
|
|
return fmt.Errorf("duplicate JSON field %s", fieldPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[key] = struct{}{}
|
|
|
|
|
|
if _, known := allowed[key]; !known {
|
|
|
|
|
|
return fmt.Errorf("unknown JSON field %s", fieldPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
if root && (key == "backendArtifacts" || key == "frontendArtifacts" || key == "nodeSsrArtifacts") {
|
|
|
|
|
|
if err := validateArtifactArray(decoder, fieldPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := consumeJSONValue(decoder, fieldPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
_, err = decoder.Token()
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// validateArtifactArray 校验下一个 JSON 值为工件数组:确认分隔符为 '[',
|
|
|
|
|
|
// 并逐个元素调用 validateObject 按 artifactManifestKeys 白名单精确校验。
|
|
|
|
|
|
// arrayPath 用于构造错误信息中的数组路径,形如 "backendArtifacts[0]"。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func validateArtifactArray(decoder *json.Decoder, arrayPath string) error {
|
|
|
|
|
|
token, err := decoder.Token()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
|
|
|
|
|
|
return fmt.Errorf("%s must be a JSON array", arrayPath)
|
|
|
|
|
|
}
|
|
|
|
|
|
index := 0
|
|
|
|
|
|
for decoder.More() {
|
|
|
|
|
|
if err := validateObject(decoder, fmt.Sprintf("%s[%d]", arrayPath, index), artifactManifestKeys, false); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
index++
|
|
|
|
|
|
}
|
|
|
|
|
|
_, err = decoder.Token()
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// consumeJSONValue 校验一个非工件字段的 JSON 值:若是标量直接通过;
|
|
|
|
|
|
// 若是数组或对象则递归检查其内部对象键不重复,以保证后续 json.Decoder 严格解码兼容。
|
|
|
|
|
|
// valuePath 用于构造错误信息中的字段路径。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func consumeJSONValue(decoder *json.Decoder, valuePath string) error {
|
|
|
|
|
|
token, err := decoder.Token()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
delimiter, composite := token.(json.Delim)
|
|
|
|
|
|
if !composite {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
switch delimiter {
|
|
|
|
|
|
case '[':
|
|
|
|
|
|
for decoder.More() {
|
|
|
|
|
|
if err := consumeJSONValue(decoder, valuePath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
_, err = decoder.Token()
|
|
|
|
|
|
return err
|
|
|
|
|
|
case '{':
|
|
|
|
|
|
seen := make(map[string]struct{})
|
|
|
|
|
|
for decoder.More() {
|
|
|
|
|
|
keyToken, err := decoder.Token()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
key := keyToken.(string)
|
|
|
|
|
|
if _, duplicate := seen[key]; duplicate {
|
|
|
|
|
|
return fmt.Errorf("duplicate JSON field %s.%s", valuePath, key)
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[key] = struct{}{}
|
|
|
|
|
|
if err := consumeJSONValue(decoder, valuePath+"."+key); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
_, err = decoder.Token()
|
|
|
|
|
|
return err
|
|
|
|
|
|
default:
|
|
|
|
|
|
return fmt.Errorf("unexpected JSON delimiter %q at %s", delimiter, valuePath)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// displayJSONPath 将 JSON 字段路径转为错误信息中可读的展示形式;
|
|
|
|
|
|
// 空路径表示清单文档本身,返回固定的文档名。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func displayJSONPath(value string) string {
|
|
|
|
|
|
if value == "" {
|
|
|
|
|
|
return "artifact-selection.json"
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// ensureJSONEnd 确认 decoder 流中除已解析的 JSON 值外再无内容;
|
|
|
|
|
|
// 若还有额外值则报多 JSON 值错误,其余解码错误原样返回。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func ensureJSONEnd(decoder *json.Decoder) error {
|
|
|
|
|
|
var trailing any
|
|
|
|
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
return errors.New("artifact-selection.json contains multiple JSON values")
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Errorf("decode artifact-selection.json trailing content: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// selectNativeBackend 从后端工件列表中选出唯一的原生后端工件:
|
|
|
|
|
|
// 要求 ArtifactKind、Type、SelectedType 分别为 "BACKEND"、"native"、"native",
|
|
|
|
|
|
// 且这样的工件恰好只有一个,否则返回错误。选中后校验 id、fileName、sha256 均非空。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func selectNativeBackend(artifacts []manifestArtifact) (manifestArtifact, error) {
|
|
|
|
|
|
var selected manifestArtifact
|
|
|
|
|
|
count := 0
|
|
|
|
|
|
for _, artifact := range artifacts {
|
|
|
|
|
|
if artifact.ArtifactKind == backendArtifactKind && artifact.Type == nativeArtifactType && artifact.SelectedType == nativeArtifactType {
|
|
|
|
|
|
selected = artifact
|
|
|
|
|
|
count++
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if count != 1 {
|
|
|
|
|
|
return manifestArtifact{}, fmt.Errorf("backendArtifacts must contain exactly one BACKEND native selection, got %d", count)
|
|
|
|
|
|
}
|
|
|
|
|
|
if selected.ID <= 0 || selected.FileName == "" || selected.SHA256 == "" {
|
|
|
|
|
|
return manifestArtifact{}, errors.New("selected native backend artifact requires id, fileName and sha256")
|
|
|
|
|
|
}
|
|
|
|
|
|
return selected, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// validateEntries 遍历 ZIP 条目并校验安全性:条目名非空、不使用反斜杠分隔、
|
|
|
|
|
|
// 非绝对路径、路径已清洗、非 "."、不以 "../" 开头、非符号链接且不重复,
|
|
|
|
|
|
// 校验通过后返回以条目名为键的映射,便于后续按根文件名定位工件。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func validateEntries(files []*zip.File) (map[string]*zip.File, error) {
|
|
|
|
|
|
entries := make(map[string]*zip.File, len(files))
|
|
|
|
|
|
for _, file := range files {
|
|
|
|
|
|
name := file.Name
|
|
|
|
|
|
if name == "" || strings.Contains(name, "\\") || path.IsAbs(name) || path.Clean(name) != name || name == "." || strings.HasPrefix(name, "../") {
|
|
|
|
|
|
return nil, fmt.Errorf("update package contains unsafe ZIP entry: %q", name)
|
|
|
|
|
|
}
|
|
|
|
|
|
if file.Mode()&os.ModeSymlink != 0 {
|
|
|
|
|
|
return nil, fmt.Errorf("update package contains symbolic link entry: %s", name)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, duplicate := entries[name]; duplicate {
|
|
|
|
|
|
return nil, fmt.Errorf("update package contains duplicate ZIP entry: %s", name)
|
|
|
|
|
|
}
|
|
|
|
|
|
entries[name] = file
|
|
|
|
|
|
}
|
|
|
|
|
|
return entries, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// validateRootFileName 校验工件文件名恰好是 ZIP 根目录下的单一文件名:
|
|
|
|
|
|
// 非空、不含路径分隔符、已清洗、非 "." 或 ".."。name 即清单中声明的 fileName。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func validateRootFileName(name string) error {
|
|
|
|
|
|
if name == "" || strings.ContainsAny(name, "/\\") || path.Clean(name) != name || name == "." || name == ".." {
|
|
|
|
|
|
return fmt.Errorf("expected one exact ZIP root file name, got %q", name)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// validateAbsoluteRegularFile 校验 filePath 为干净绝对路径且经 Lstat 确认是
|
|
|
|
|
|
// 直接普通文件(非符号链接、非目录等)。description 用于描述被校验文件用途,
|
|
|
|
|
|
// 以构造可读的错误信息。校验通过返回 nil。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func validateAbsoluteRegularFile(filePath string, description string) error {
|
|
|
|
|
|
if !filepath.IsAbs(filePath) || filepath.Clean(filePath) != filePath {
|
|
|
|
|
|
return fmt.Errorf("%s path must be a clean absolute path", description)
|
|
|
|
|
|
}
|
|
|
|
|
|
info, err := os.Lstat(filePath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("inspect %s %s: %w", description, filePath, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
|
|
|
|
|
return fmt.Errorf("%s is not a direct regular file: %s", description, filePath)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// hashFile 计算指定文件的 SHA-256 摘要并返回其十六进制字符串。
|
|
|
|
|
|
// 读取与关闭过程中任一错误都会以包裹形式返回。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func hashFile(filePath string) (string, error) {
|
|
|
|
|
|
file, err := os.Open(filePath)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("open update package for SHA-256: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
digest := sha256.New()
|
|
|
|
|
|
_, copyErr := io.Copy(digest, file)
|
|
|
|
|
|
closeErr := file.Close()
|
|
|
|
|
|
if err := errors.Join(copyErr, closeErr); err != nil {
|
|
|
|
|
|
return "", fmt.Errorf("hash update package: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return hex.EncodeToString(digest.Sum(nil)), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-17 10:10:14 +08:00
|
|
|
|
// syncDirectory 打开 directory 并调用 Sync 将目录项刷入磁盘,
|
|
|
|
|
|
// 用于在原子重命名后确保新文件在目录中的落盘。打开、同步或关闭失败均返回错误。
|
2026-08-16 01:27:30 +08:00
|
|
|
|
func syncDirectory(directory string) error {
|
|
|
|
|
|
file, err := os.Open(directory)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("open extraction directory for flush: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
syncErr := file.Sync()
|
|
|
|
|
|
closeErr := file.Close()
|
|
|
|
|
|
if err := errors.Join(syncErr, closeErr); err != nil {
|
|
|
|
|
|
return fmt.Errorf("flush extraction directory: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|