feat: backend native executor implement
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package updatepackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenAndExtractNativeBackend(t *testing.T) {
|
||||
jar := []byte("native backend JAR")
|
||||
packagePath := writeBackendPackage(t, jar, nil)
|
||||
|
||||
updatePackage, err := OpenBackendNative(packagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open native backend package: %v", err)
|
||||
}
|
||||
defer updatePackage.Close()
|
||||
if updatePackage.CustomerCode != "customer-01" || updatePackage.VersionID != "V1.1.8" || updatePackage.ArtifactID != 42 {
|
||||
t.Fatalf("unexpected package metadata: %+v", updatePackage)
|
||||
}
|
||||
if updatePackage.Identity.Size != int64(len(jar)) || updatePackage.PackageSHA256 == "" {
|
||||
t.Fatalf("unexpected package identity: package=%s artifact=%+v", updatePackage.PackageSHA256, updatePackage.Identity)
|
||||
}
|
||||
|
||||
destination := filepath.Join(t.TempDir(), "incoming", "backend.jar")
|
||||
if err := updatePackage.ExtractArtifact(destination); err != nil {
|
||||
t.Fatalf("extract native backend artifact: %v", err)
|
||||
}
|
||||
actual, err := os.ReadFile(destination)
|
||||
if err != nil || !bytes.Equal(actual, jar) {
|
||||
t.Fatalf("unexpected extracted artifact: content=%q err=%v", actual, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRejectsUnknownManifestField(t *testing.T) {
|
||||
jar := []byte("native backend JAR")
|
||||
packagePath := writeBackendPackage(t, jar, func(manifest map[string]any) {
|
||||
manifest["VersionID"] = "wrong-case"
|
||||
})
|
||||
_, err := OpenBackendNative(packagePath)
|
||||
if err == nil || !strings.Contains(err.Error(), "VersionID") {
|
||||
t.Fatalf("expected unknown manifest field rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRejectsMissingDeclaredArtifact(t *testing.T) {
|
||||
jar := []byte("native backend JAR")
|
||||
packagePath := writePackageEntries(t, map[string][]byte{
|
||||
manifestName: manifestJSON(t, jar, func(manifest map[string]any) {
|
||||
artifacts := manifest["backendArtifacts"].([]any)
|
||||
artifacts[0].(map[string]any)["fileName"] = "missing.jar"
|
||||
}),
|
||||
})
|
||||
_, err := OpenBackendNative(packagePath)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing.jar") {
|
||||
t.Fatalf("expected missing artifact rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRejectsArtifactDigestMismatch(t *testing.T) {
|
||||
jar := []byte("native backend JAR")
|
||||
packagePath := writeBackendPackage(t, jar, func(manifest map[string]any) {
|
||||
artifacts := manifest["backendArtifacts"].([]any)
|
||||
artifacts[0].(map[string]any)["sha256"] = strings.Repeat("0", 64)
|
||||
})
|
||||
updatePackage, err := OpenBackendNative(packagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open package before digest verification: %v", err)
|
||||
}
|
||||
defer updatePackage.Close()
|
||||
destination := filepath.Join(t.TempDir(), "backend.jar")
|
||||
if err := updatePackage.ExtractArtifact(destination); err == nil || !strings.Contains(err.Error(), "SHA-256 mismatch") {
|
||||
t.Fatalf("expected artifact digest mismatch, got %v", err)
|
||||
}
|
||||
if _, err := os.Stat(destination); !os.IsNotExist(err) {
|
||||
t.Fatalf("digest mismatch published artifact: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRejectsUnsafeAndDuplicateEntries(t *testing.T) {
|
||||
jar := []byte("native backend JAR")
|
||||
manifest := manifestJSON(t, jar, nil)
|
||||
|
||||
t.Run("unsafe", func(t *testing.T) {
|
||||
packagePath := writePackageEntries(t, map[string][]byte{
|
||||
manifestName: manifest,
|
||||
"backend.jar": jar,
|
||||
"../escape": []byte("escape"),
|
||||
})
|
||||
if _, err := OpenBackendNative(packagePath); err == nil {
|
||||
t.Fatal("expected unsafe ZIP entry rejection")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "package.zip")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatalf("create duplicate package: %v", err)
|
||||
}
|
||||
writer := zip.NewWriter(file)
|
||||
for _, content := range [][]byte{manifest, manifest} {
|
||||
entry, err := writer.Create(manifestName)
|
||||
if err != nil {
|
||||
t.Fatalf("create duplicate manifest entry: %v", err)
|
||||
}
|
||||
if _, err := entry.Write(content); err != nil {
|
||||
t.Fatalf("write duplicate manifest entry: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close duplicate package writer: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("close duplicate package: %v", err)
|
||||
}
|
||||
if _, err := OpenBackendNative(path); err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Fatalf("expected duplicate ZIP entry rejection, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func writeBackendPackage(t *testing.T, jar []byte, modify func(map[string]any)) string {
|
||||
t.Helper()
|
||||
return writePackageEntries(t, map[string][]byte{
|
||||
manifestName: manifestJSON(t, jar, modify),
|
||||
"backend.jar": jar,
|
||||
})
|
||||
}
|
||||
|
||||
func manifestJSON(t *testing.T, jar []byte, modify func(map[string]any)) []byte {
|
||||
t.Helper()
|
||||
digest := sha256.Sum256(jar)
|
||||
manifest := map[string]any{
|
||||
"customerCode": "customer-01",
|
||||
"customerDisplayName": "Customer 01",
|
||||
"versionId": "V1.1.8",
|
||||
"items": []string{"deploy-sync.sh"},
|
||||
"backendArtifacts": []any{map[string]any{
|
||||
"id": int64(42),
|
||||
"versionCode": "V1.1.8",
|
||||
"artifactKind": backendArtifactKind,
|
||||
"type": nativeArtifactType,
|
||||
"selectedType": nativeArtifactType,
|
||||
"platform": nil,
|
||||
"fileName": "backend.jar",
|
||||
"filePath": "/archive/backend.jar",
|
||||
"sha256": hex.EncodeToString(digest[:]),
|
||||
"imageRef": nil,
|
||||
}},
|
||||
"frontendArtifacts": []any{},
|
||||
"nodeSsrArtifacts": []any{},
|
||||
"remark": nil,
|
||||
}
|
||||
if modify != nil {
|
||||
modify(manifest)
|
||||
}
|
||||
content, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("encode manifest: %v", err)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func writePackageEntries(t *testing.T, entries map[string][]byte) string {
|
||||
t.Helper()
|
||||
packagePath := filepath.Join(t.TempDir(), "package.zip")
|
||||
file, err := os.Create(packagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("create package: %v", err)
|
||||
}
|
||||
writer := zip.NewWriter(file)
|
||||
for name, content := range entries {
|
||||
entry, err := writer.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("create package entry %s: %v", name, err)
|
||||
}
|
||||
if _, err := entry.Write(content); err != nil {
|
||||
t.Fatalf("write package entry %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close package writer: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("close package: %v", err)
|
||||
}
|
||||
return packagePath
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package updatepackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"yms-daemon/internal/filestore"
|
||||
)
|
||||
|
||||
// DirectNativeJAR is one JAR supplied directly by Jenkins or an operator.
|
||||
// FileName is treated as opaque text; no version is parsed from it.
|
||||
type DirectNativeJAR struct {
|
||||
Path string
|
||||
SHA256 string
|
||||
FileName string
|
||||
Identity filestore.Identity
|
||||
}
|
||||
|
||||
// OpenDirectNativeJAR validates the direct file, verifies every ZIP entry and
|
||||
// records the immutable identity used by the transaction.
|
||||
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 copies the exact JAR into transaction storage and rejects a
|
||||
// source file that changes after OpenDirectNativeJAR returns.
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package updatepackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenAndCopyDirectNativeJAR(t *testing.T) {
|
||||
jarPath := writeDirectNativeJAR(t, "glory-soft-yms.jar", []byte("backend classes"))
|
||||
jar, err := OpenDirectNativeJAR(jarPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open direct native backend JAR: %v", err)
|
||||
}
|
||||
if jar.FileName != "glory-soft-yms.jar" || jar.SHA256 == "" || jar.Identity.Size <= 0 {
|
||||
t.Fatalf("unexpected direct native backend JAR metadata: %+v", jar)
|
||||
}
|
||||
|
||||
destination := filepath.Join(t.TempDir(), "transaction", "backend.jar")
|
||||
if err := jar.CopyArtifact(destination); err != nil {
|
||||
t.Fatalf("copy direct native backend JAR: %v", err)
|
||||
}
|
||||
expected, err := os.ReadFile(jarPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read source JAR: %v", err)
|
||||
}
|
||||
actual, err := os.ReadFile(destination)
|
||||
if err != nil || !bytes.Equal(actual, expected) {
|
||||
t.Fatalf("unexpected copied JAR: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenDirectNativeJARRejectsInvalidArchive(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "glory-soft-yms.jar")
|
||||
if err := os.WriteFile(path, []byte("not a JAR"), 0o600); err != nil {
|
||||
t.Fatalf("write invalid JAR: %v", err)
|
||||
}
|
||||
if _, err := OpenDirectNativeJAR(path); err == nil {
|
||||
t.Fatal("expected invalid direct JAR rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyDirectNativeJARRejectsChangedSource(t *testing.T) {
|
||||
jarPath := writeDirectNativeJAR(t, "glory-soft-yms.jar", []byte("first"))
|
||||
jar, err := OpenDirectNativeJAR(jarPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open direct native backend JAR: %v", err)
|
||||
}
|
||||
replacement := writeDirectNativeJAR(t, "replacement.jar", []byte("second"))
|
||||
content, err := os.ReadFile(replacement)
|
||||
if err != nil {
|
||||
t.Fatalf("read replacement JAR: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(jarPath, content, 0o600); err != nil {
|
||||
t.Fatalf("replace source JAR: %v", err)
|
||||
}
|
||||
err = jar.CopyArtifact(filepath.Join(t.TempDir(), "backend.jar"))
|
||||
if err == nil || !strings.Contains(err.Error(), "changed") {
|
||||
t.Fatalf("expected changed source rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeDirectNativeJAR(t *testing.T, fileName string, content []byte) string {
|
||||
t.Helper()
|
||||
jarPath := filepath.Join(t.TempDir(), fileName)
|
||||
file, err := os.Create(jarPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create direct JAR: %v", err)
|
||||
}
|
||||
writer := zip.NewWriter(file)
|
||||
entry, err := writer.Create("BOOT-INF/classes/application.properties")
|
||||
if err != nil {
|
||||
t.Fatalf("create direct JAR entry: %v", err)
|
||||
}
|
||||
if _, err := entry.Write(content); err != nil {
|
||||
t.Fatalf("write direct JAR entry: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close direct JAR writer: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("close direct JAR: %v", err)
|
||||
}
|
||||
return jarPath
|
||||
}
|
||||
Reference in New Issue
Block a user