// 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 }