484 lines
15 KiB
Go
484 lines
15 KiB
Go
// Package updatepackage reads the exact repack ZIP format currently emitted by deploy.
|
|
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 = "artifact-selection.json"
|
|
backendArtifactKind = "BACKEND"
|
|
nativeArtifactType = "native"
|
|
maximumManifestBytes = 1 << 20
|
|
)
|
|
|
|
// BackendNativePackage is an opened repack ZIP containing one exact native backend artifact.
|
|
type BackendNativePackage struct {
|
|
archive *zip.ReadCloser
|
|
artifactEntry *zip.File
|
|
|
|
PackagePath string
|
|
PackageSHA256 string
|
|
CustomerCode string
|
|
VersionID string
|
|
ArtifactID int64
|
|
FileName string
|
|
Identity filestore.Identity
|
|
}
|
|
|
|
// OpenBackendNative validates the ZIP structure and selects one explicitly declared native backend artifact.
|
|
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 releases the opened ZIP file.
|
|
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 writes the selected JAR to destination and verifies size and SHA-256 before publishing it.
|
|
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)
|
|
}
|
|
|
|
type artifactSelectionManifest struct {
|
|
CustomerCode string `json:"customerCode"`
|
|
CustomerDisplayName *string `json:"customerDisplayName"`
|
|
VersionID string `json:"versionId"`
|
|
Items []string `json:"items"`
|
|
BackendArtifacts []manifestArtifact `json:"backendArtifacts"`
|
|
FrontendArtifacts []manifestArtifact `json:"frontendArtifacts"`
|
|
NodeSSRArtifacts []manifestArtifact `json:"nodeSsrArtifacts"`
|
|
Remark *string `json:"remark"`
|
|
}
|
|
|
|
type manifestArtifact struct {
|
|
ID int64 `json:"id"`
|
|
VersionCode *string `json:"versionCode"`
|
|
ArtifactKind string `json:"artifactKind"`
|
|
Type string `json:"type"`
|
|
SelectedType string `json:"selectedType"`
|
|
Platform *string `json:"platform"`
|
|
FileName string `json:"fileName"`
|
|
FilePath *string `json:"filePath"`
|
|
SHA256 string `json:"sha256"`
|
|
ImageRef *string `json:"imageRef"`
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var rootManifestKeys = map[string]struct{}{
|
|
"customerCode": {},
|
|
"customerDisplayName": {},
|
|
"versionId": {},
|
|
"items": {},
|
|
"backendArtifacts": {},
|
|
"frontendArtifacts": {},
|
|
"nodeSsrArtifacts": {},
|
|
"remark": {},
|
|
}
|
|
|
|
var artifactManifestKeys = map[string]struct{}{
|
|
"id": {},
|
|
"versionCode": {},
|
|
"artifactKind": {},
|
|
"type": {},
|
|
"selectedType": {},
|
|
"platform": {},
|
|
"fileName": {},
|
|
"filePath": {},
|
|
"sha256": {},
|
|
"imageRef": {},
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func displayJSONPath(value string) string {
|
|
if value == "" {
|
|
return "artifact-selection.json"
|
|
}
|
|
return value
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|