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 }