Files
yms-daemon/internal/updatepackage/backend_native.go
T
2026-08-17 10:10:14 +08:00

572 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package updatepackage 负责解析 deploy 当前产出的重打包 ZIP 更新包格式,
// 并从中提取、校验原生后端工件。包内同时支持两种来源:通过
// artifact-selection.json 清单声明的后端原生工件(backend_native.go),以及
// 由 Jenkins 或运维人员直接提供的 JAR 文件(direct_native.go)。包边界仅限
// 更新包的解析、结构校验与工件落地,不涉及具体部署流程。
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 (
// manifestName 更新包根目录下描述工件选择结果的清单文件名。
manifestName = "artifact-selection.json"
// backendArtifactKind 表示清单中后端工件的类别,取值须为 "BACKEND"。
backendArtifactKind = "BACKEND"
// nativeArtifactType 表示原生工件类型,type 与 selectedType 均须为 "native"。
nativeArtifactType = "native"
// maximumManifestBytes 清单文件允许的最大未压缩字节数,用于防止恶意超大清单。
maximumManifestBytes = 1 << 20
)
// BackendNativePackage 一个已打开的、恰好包含一个原生后端工件的重打包 ZIP 更新包。
// 它封装了底层 ZIP 句柄与被选中工件对应的条目,并通过导出字段暴露解析出的元数据。
// 不变量:该包在被选中后始终只对应一个明确的原生后端工件;Close 之后不得再使用。
type BackendNativePackage struct {
// archive 底层打开的 ZIP 句柄,Close 时释放。
archive *zip.ReadCloser
// artifactEntry 被选中原生后端工件在 ZIP 中对应的条目。
artifactEntry *zip.File
// PackagePath 更新包文件的绝对路径。
PackagePath string
// PackageSHA256 整个更新包文件的 SHA-256 摘要(十六进制)。
PackageSHA256 string
// CustomerCode 从清单中解析出的客户编码。
CustomerCode string
// VersionID 从清单中解析出的版本标识。
VersionID string
// ArtifactID 被选中工件在清单中声明的 ID。
ArtifactID int64
// FileName 被选中工件在 ZIP 根目录下的文件名。
FileName string
// Identity 被选中工件的不可变身份(未压缩大小与 SHA-256)。
Identity filestore.Identity
}
// OpenBackendNative 校验更新包文件路径、计算包摘要并解析 ZIP 结构,
// 从 artifact-selection.json 清单中选出恰好一个显式声明的原生后端工件,
// 返回已打开的 BackendNativePackage。
// packagePath 必须是干净绝对路径且为直接普通文件;校验失败时返回错误。
// 成功返回后,调用方须通过 Close 释放底层 ZIP 句柄。
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
}
// Close 释放底层 ZIP 文件句柄并清空内部条目引用。对 nil 接收者或已关闭的包,
// 重复调用幂等返回 nil。关闭后包不再可用。
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()
}
// ExtractArtifact 将被选中的原生后端 JAR 写入 destination,并在发布前校验
// 未压缩大小与 SHA-256 摘要,两者均匹配后才原子重命名到位。
// destination 必须是干净绝对路径;其父目录会被创建且须为直接目录(非符号链接)。
// 过程先写入临时文件、同步后重命名,再同步父目录;任何一步失败都会清理临时文件,
// 保证 destination 不会残留半成品。
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)
}
// artifactSelectionManifest artifact-selection.json 清单的结构化表示。
// 字段标签严格对应 deploy 产出的 JSON 键名,不允许未知字段。
type artifactSelectionManifest struct {
// 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"`
}
// manifestArtifact 清单中单个工件的结构化表示。
type manifestArtifact struct {
// 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"`
}
// decodeManifest 读取并解析 ZIP 中名为 artifact-selection.json 的清单条目。
// entry 必须是普通文件且不超过 maximumManifestBytes;解析前先做精确键名校验,
// 再禁用未知字段进行解码,最后校验 customerCode 与 versionId 均非空。
// 任何一步失败都会返回描述性的错误。
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
}
// rootManifestKeys 清单根对象允许出现的所有 JSON 键名集合,
// 用于精确校验,拒绝任何未知键(包括大小写不匹配的键)。
var rootManifestKeys = map[string]struct{}{
"customerCode": {},
"customerDisplayName": {},
"versionId": {},
"items": {},
"backendArtifacts": {},
"frontendArtifacts": {},
"nodeSsrArtifacts": {},
"remark": {},
}
// artifactManifestKeys 单个工件对象允许出现的所有 JSON 键名集合。
var artifactManifestKeys = map[string]struct{}{
"id": {},
"versionCode": {},
"artifactKind": {},
"type": {},
"selectedType": {},
"platform": {},
"fileName": {},
"filePath": {},
"sha256": {},
"imageRef": {},
}
// validateExactManifestJSON 使用 json.Decoder 对清单文档做递归精确校验:
// 根对象与各工件对象的键名必须落在预定义白名单内,且不允许重复键与未知键。
// document 必须是单一 JSON 值,其后不得有额外内容。
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)
}
// validateObject 校验下一个 JSON 值为对象:确认分隔符为 '{',逐键检查重复与
// 未知字段;root 为 true 时,对三个工件列表字段递归调用 validateArtifactArray
// 其余字段则通过 consumeJSONValue 仅做语法与重复键校验。objectPath 用于构造
// 错误信息中的字段路径。
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
}
// validateArtifactArray 校验下一个 JSON 值为工件数组:确认分隔符为 '['
// 并逐个元素调用 validateObject 按 artifactManifestKeys 白名单精确校验。
// arrayPath 用于构造错误信息中的数组路径,形如 "backendArtifacts[0]"。
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
}
// consumeJSONValue 校验一个非工件字段的 JSON 值:若是标量直接通过;
// 若是数组或对象则递归检查其内部对象键不重复,以保证后续 json.Decoder 严格解码兼容。
// valuePath 用于构造错误信息中的字段路径。
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)
}
}
// displayJSONPath 将 JSON 字段路径转为错误信息中可读的展示形式;
// 空路径表示清单文档本身,返回固定的文档名。
func displayJSONPath(value string) string {
if value == "" {
return "artifact-selection.json"
}
return value
}
// ensureJSONEnd 确认 decoder 流中除已解析的 JSON 值外再无内容;
// 若还有额外值则报多 JSON 值错误,其余解码错误原样返回。
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
}
// selectNativeBackend 从后端工件列表中选出唯一的原生后端工件:
// 要求 ArtifactKind、Type、SelectedType 分别为 "BACKEND"、"native"、"native"
// 且这样的工件恰好只有一个,否则返回错误。选中后校验 id、fileName、sha256 均非空。
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
}
// validateEntries 遍历 ZIP 条目并校验安全性:条目名非空、不使用反斜杠分隔、
// 非绝对路径、路径已清洗、非 "."、不以 "../" 开头、非符号链接且不重复,
// 校验通过后返回以条目名为键的映射,便于后续按根文件名定位工件。
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
}
// validateRootFileName 校验工件文件名恰好是 ZIP 根目录下的单一文件名:
// 非空、不含路径分隔符、已清洗、非 "." 或 ".."。name 即清单中声明的 fileName。
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
}
// validateAbsoluteRegularFile 校验 filePath 为干净绝对路径且经 Lstat 确认是
// 直接普通文件(非符号链接、非目录等)。description 用于描述被校验文件用途,
// 以构造可读的错误信息。校验通过返回 nil。
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
}
// hashFile 计算指定文件的 SHA-256 摘要并返回其十六进制字符串。
// 读取与关闭过程中任一错误都会以包裹形式返回。
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
}
// syncDirectory 打开 directory 并调用 Sync 将目录项刷入磁盘,
// 用于在原子重命名后确保新文件在目录中的落盘。打开、同步或关闭失败均返回错误。
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
}