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

164 lines
5.9 KiB
Go

package updatepackage
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"yms-daemon/internal/filestore"
)
// DirectNativeJAR 表示由 Jenkins 或运维人员直接提供的一个原生后端 JAR 文件。
// FileName 被当作不透明文本处理,不会从中解析任何版本信息。
type DirectNativeJAR struct {
// Path JAR 文件的绝对路径。
Path string
// SHA256 JAR 文件的十六进制 SHA-256 摘要。
SHA256 string
// FileName JAR 文件的基础文件名(不含目录)。
FileName string
// Identity JAR 文件的不可变身份(大小与 SHA-256),用于事务校验。
Identity filestore.Identity
}
// OpenDirectNativeJAR 校验直接提供的 JAR 文件:确认其为干净绝对路径下的直接
// 普通文件、文件名以 ".jar" 结尾,并打开验证 ZIP 内所有条目可完整读取,
// 最后记录事务所需的不可变身份。任一校验失败返回错误。
func OpenDirectNativeJAR(jarPath string) (DirectNativeJAR, error) {
if err := validateAbsoluteRegularFile(jarPath, "native backend JAR"); err != nil {
return DirectNativeJAR{}, err
}
fileName := filepath.Base(jarPath)
if filepath.Ext(fileName) != ".jar" {
return DirectNativeJAR{}, fmt.Errorf("native backend JAR file name must end with .jar: %s", fileName)
}
if err := verifyJARArchive(jarPath); err != nil {
return DirectNativeJAR{}, err
}
identity, err := identifyFile(jarPath)
if err != nil {
return DirectNativeJAR{}, err
}
return DirectNativeJAR{
Path: jarPath,
SHA256: identity.SHA256,
FileName: fileName,
Identity: identity,
}, nil
}
// CopyArtifact 将 JAR 原样复制到事务存储的 destination,并拒绝在
// OpenDirectNativeJAR 返回之后内容发生变化的源文件。destination 必须是干净
// 绝对路径;复制过程先写临时文件,边复制边计算摘要,比对大小与 SHA-256 一致后
// 对临时副本再次校验 ZIP,最后原子重命名并同步父目录。任一步失败均清理临时文件。
func (j DirectNativeJAR) CopyArtifact(destination string) error {
if !filepath.IsAbs(destination) || filepath.Clean(destination) != destination {
return errors.New("native backend JAR 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 JAR destination directory: %w", err)
}
parentInfo, err := os.Lstat(parent)
if err != nil {
return fmt.Errorf("inspect native backend JAR destination directory: %w", err)
}
if !parentInfo.IsDir() || parentInfo.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("native backend JAR destination parent is not a direct directory: %s", parent)
}
source, err := os.Open(j.Path)
if err != nil {
return fmt.Errorf("open native backend JAR: %w", err)
}
temporary, err := os.CreateTemp(parent, ".backend-jar-*")
if err != nil {
_ = source.Close()
return fmt.Errorf("create native backend JAR transaction 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 JAR transaction file 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("copy native backend JAR: %w", err)
}
actualDigest := hex.EncodeToString(digest.Sum(nil))
if written != j.Identity.Size || !strings.EqualFold(actualDigest, j.Identity.SHA256) {
_ = temporary.Close()
return errors.New("native backend JAR changed while entering the transaction")
}
if err := temporary.Sync(); err != nil {
_ = temporary.Close()
return fmt.Errorf("flush native backend JAR transaction file: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close native backend JAR transaction file: %w", err)
}
if err := verifyJARArchive(temporaryPath); err != nil {
return fmt.Errorf("verify copied native backend JAR: %w", err)
}
if err := os.Rename(temporaryPath, destination); err != nil {
return fmt.Errorf("publish native backend JAR transaction file: %w", err)
}
return syncDirectory(parent)
}
// verifyJARArchive 打开 jarPath 对应的 ZIP 并逐条目完整读取(丢弃内容),
// 确认归档结构有效且每个非目录条目可解压。空归档或任一读取失败均返回错误。
func verifyJARArchive(jarPath string) error {
archive, err := zip.OpenReader(jarPath)
if err != nil {
return fmt.Errorf("open native backend JAR %s: %w", jarPath, err)
}
defer archive.Close()
if len(archive.File) == 0 {
return errors.New("native backend JAR contains no ZIP entries")
}
for _, entry := range archive.File {
if entry.FileInfo().IsDir() {
continue
}
reader, err := entry.Open()
if err != nil {
return fmt.Errorf("open native backend JAR entry %s: %w", entry.Name, err)
}
_, readErr := io.Copy(io.Discard, reader)
closeErr := reader.Close()
if err := errors.Join(readErr, closeErr); err != nil {
return fmt.Errorf("verify native backend JAR entry %s: %w", entry.Name, err)
}
}
return nil
}
// identifyFile 计算指定文件的大小与 SHA-256 摘要,返回不可变身份 Identity。
// 读取或关闭失败时返回错误。
func identifyFile(filePath string) (filestore.Identity, error) {
file, err := os.Open(filePath)
if err != nil {
return filestore.Identity{}, fmt.Errorf("open native backend JAR for identity: %w", err)
}
digest := sha256.New()
size, copyErr := io.Copy(digest, file)
closeErr := file.Close()
if err := errors.Join(copyErr, closeErr); err != nil {
return filestore.Identity{}, fmt.Errorf("identify native backend JAR: %w", err)
}
return filestore.Identity{Size: size, SHA256: hex.EncodeToString(digest.Sum(nil))}, nil
}