536 lines
19 KiB
Go
536 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
maxMarketplaceIndexBytes = 2 << 20
|
|
maxMarketplaceEntries = 256
|
|
)
|
|
|
|
var (
|
|
marketplaceIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)+$`)
|
|
marketplaceVersionPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
|
|
marketplaceSHA256Pattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
|
)
|
|
|
|
// marketplaceEntry is deliberately metadata-only. The browser never receives
|
|
// the archive URL; downloads are performed by this server after catalog and
|
|
// transport policy validation.
|
|
type marketplaceEntry struct {
|
|
PluginID string `json:"plugin_id"`
|
|
Name string `json:"name"`
|
|
Version string `json:"version"`
|
|
Description string `json:"description,omitempty"`
|
|
ArchiveURL string `json:"archive_url"`
|
|
ArchiveSHA256 string `json:"archive_sha256"`
|
|
ArchiveSize int64 `json:"archive_size,omitempty"`
|
|
PublisherKeyID string `json:"publisher_key_id"`
|
|
CoreAPIBaseline string `json:"core_api_baseline"`
|
|
TestedCoreVersions []string `json:"tested_core_versions,omitempty"`
|
|
Capabilities []string `json:"capabilities,omitempty"`
|
|
ReleaseNotes string `json:"release_notes,omitempty"`
|
|
PublishedAt string `json:"published_at,omitempty"`
|
|
}
|
|
|
|
type marketplaceIndex struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
Source string `json:"source,omitempty"`
|
|
IssuedAt string `json:"issued_at,omitempty"`
|
|
ExpiresAt string `json:"expires_at,omitempty"`
|
|
Entries []marketplaceEntry `json:"entries"`
|
|
}
|
|
|
|
type marketplaceService struct {
|
|
localPath string
|
|
remoteURL *url.URL
|
|
allowedHosts map[string]struct{}
|
|
allowLoopback bool
|
|
client *http.Client
|
|
}
|
|
|
|
type marketplaceOrigin struct {
|
|
localPath string
|
|
remoteURL *url.URL
|
|
}
|
|
|
|
func (m marketplaceService) httpClient() *http.Client {
|
|
if m.client != nil {
|
|
return m.client
|
|
}
|
|
return &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
Transport: &http.Transport{Proxy: nil, DialContext: marketplaceDialContext(m.allowLoopback)},
|
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
}
|
|
|
|
func newMarketplaceService(source, allowedHosts string, allowLoopback bool) (marketplaceService, error) {
|
|
if strings.TrimSpace(source) == "" {
|
|
source = "./marketplace/index.json"
|
|
}
|
|
service := marketplaceService{
|
|
allowedHosts: map[string]struct{}{},
|
|
allowLoopback: allowLoopback,
|
|
client: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
Transport: &http.Transport{Proxy: nil, DialContext: marketplaceDialContext(allowLoopback)},
|
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
},
|
|
}
|
|
parsed, err := url.Parse(strings.TrimSpace(source))
|
|
if err == nil && parsed.Scheme != "" {
|
|
if parsed.User != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
|
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX must be an HTTPS URL without credentials, query or fragment")
|
|
}
|
|
if parsed.Scheme != "https" && !(allowLoopback && isLoopbackHost(parsed.Hostname())) {
|
|
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX must use HTTPS unless it targets loopback in development")
|
|
}
|
|
service.remoteURL = parsed
|
|
for _, host := range strings.FieldsFunc(allowedHosts, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) {
|
|
host = canonicalAllowedMarketplaceHost(host)
|
|
if host != "" {
|
|
service.allowedHosts[host] = struct{}{}
|
|
}
|
|
}
|
|
if len(service.allowedHosts) == 0 {
|
|
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_ALLOWED_HOSTS is required for remote marketplace indexes")
|
|
}
|
|
if !service.hostAllowed(parsed) {
|
|
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX host is not allowlisted")
|
|
}
|
|
return service, nil
|
|
}
|
|
if strings.Contains(source, "\x00") {
|
|
return marketplaceService{}, errors.New("PLUGIN_MARKETPLACE_INDEX contains an invalid path")
|
|
}
|
|
absolute, err := filepath.Abs(source)
|
|
if err != nil {
|
|
return marketplaceService{}, fmt.Errorf("resolve marketplace index: %w", err)
|
|
}
|
|
service.localPath = filepath.Clean(absolute)
|
|
return service, nil
|
|
}
|
|
|
|
func (m marketplaceService) hostAllowed(u *url.URL) bool {
|
|
if u == nil {
|
|
return false
|
|
}
|
|
_, ok := m.allowedHosts[canonicalMarketplaceHost(u)]
|
|
return ok
|
|
}
|
|
|
|
func canonicalMarketplaceHost(u *url.URL) string {
|
|
if u == nil {
|
|
return ""
|
|
}
|
|
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(u.Hostname()), "."))
|
|
if host == "" {
|
|
return ""
|
|
}
|
|
port := u.Port()
|
|
if (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") {
|
|
port = ""
|
|
}
|
|
if port == "" {
|
|
return host
|
|
}
|
|
return net.JoinHostPort(host, port)
|
|
}
|
|
|
|
func canonicalAllowedMarketplaceHost(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse("//" + raw)
|
|
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return strings.ToLower(strings.TrimSuffix(raw, "."))
|
|
}
|
|
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
|
|
port := parsed.Port()
|
|
if port == "80" || port == "443" {
|
|
port = ""
|
|
}
|
|
if port == "" {
|
|
return host
|
|
}
|
|
return net.JoinHostPort(host, port)
|
|
}
|
|
|
|
func (m marketplaceService) loadIndex(ctx context.Context) (marketplaceIndex, marketplaceOrigin, error) {
|
|
var raw []byte
|
|
origin := marketplaceOrigin{localPath: m.localPath, remoteURL: m.remoteURL}
|
|
if m.remoteURL != nil {
|
|
if !m.hostAllowed(m.remoteURL) {
|
|
return marketplaceIndex{}, origin, errors.New("marketplace index host is not allowlisted")
|
|
}
|
|
if err := m.validateRemoteHost(ctx, m.remoteURL); err != nil {
|
|
return marketplaceIndex{}, origin, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.remoteURL.String(), nil)
|
|
if err != nil {
|
|
return marketplaceIndex{}, origin, err
|
|
}
|
|
res, err := m.httpClient().Do(req)
|
|
if err != nil {
|
|
return marketplaceIndex{}, origin, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
|
return marketplaceIndex{}, origin, fmt.Errorf("marketplace index returned HTTP %d", res.StatusCode)
|
|
}
|
|
if res.ContentLength > maxMarketplaceIndexBytes {
|
|
return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit")
|
|
}
|
|
raw, err = io.ReadAll(io.LimitReader(res.Body, maxMarketplaceIndexBytes+1))
|
|
if err != nil {
|
|
return marketplaceIndex{}, origin, err
|
|
}
|
|
if len(raw) > maxMarketplaceIndexBytes {
|
|
return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit")
|
|
}
|
|
} else {
|
|
if m.localPath == "" {
|
|
return marketplaceIndex{}, origin, errors.New("marketplace index is not configured")
|
|
}
|
|
file, err := os.Open(m.localPath)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return marketplaceIndex{SchemaVersion: 1, Entries: []marketplaceEntry{}}, origin, nil
|
|
}
|
|
return marketplaceIndex{}, origin, err
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return marketplaceIndex{}, origin, err
|
|
}
|
|
if info.Size() > maxMarketplaceIndexBytes {
|
|
return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit")
|
|
}
|
|
raw, err = io.ReadAll(io.LimitReader(file, maxMarketplaceIndexBytes+1))
|
|
if err != nil {
|
|
return marketplaceIndex{}, origin, err
|
|
}
|
|
if len(raw) > maxMarketplaceIndexBytes {
|
|
return marketplaceIndex{}, origin, errors.New("marketplace index exceeds size limit")
|
|
}
|
|
}
|
|
var index marketplaceIndex
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&index); err != nil {
|
|
return marketplaceIndex{}, origin, fmt.Errorf("decode marketplace index: %w", err)
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
if err == nil {
|
|
return marketplaceIndex{}, origin, errors.New("marketplace index must contain one JSON value")
|
|
}
|
|
return marketplaceIndex{}, origin, fmt.Errorf("decode marketplace index trailing data: %w", err)
|
|
}
|
|
if err := validateMarketplaceIndex(index); err != nil {
|
|
return marketplaceIndex{}, origin, err
|
|
}
|
|
if m.remoteURL != nil && strings.TrimSpace(index.ExpiresAt) == "" {
|
|
return marketplaceIndex{}, origin, errors.New("remote marketplace index must include expires_at")
|
|
}
|
|
return index, origin, nil
|
|
}
|
|
|
|
func (m marketplaceService) validateRemoteHost(ctx context.Context, u *url.URL) error {
|
|
if u == nil || u.Hostname() == "" {
|
|
return errors.New("marketplace URL host is required")
|
|
}
|
|
lookupCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
ips, err := net.DefaultResolver.LookupIP(lookupCtx, "ip", u.Hostname())
|
|
if err != nil {
|
|
return errors.New("marketplace host DNS lookup failed")
|
|
}
|
|
if len(ips) == 0 {
|
|
return errors.New("marketplace host has no address")
|
|
}
|
|
for _, ip := range ips {
|
|
if isForbiddenMarketplaceIP(ip) && !(m.allowLoopback && ip.IsLoopback()) {
|
|
return errors.New("marketplace host resolves to a private address")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isForbiddenMarketplaceIP(ip net.IP) bool {
|
|
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() || ip.IsMulticast()
|
|
}
|
|
|
|
func validateMarketplaceIndex(index marketplaceIndex) error {
|
|
if index.SchemaVersion != 1 {
|
|
return errors.New("marketplace schema_version must be 1")
|
|
}
|
|
if len(index.Entries) > maxMarketplaceEntries {
|
|
return errors.New("marketplace contains too many entries")
|
|
}
|
|
if value := strings.TrimSpace(index.IssuedAt); value != "" {
|
|
if issued, err := time.Parse(time.RFC3339, value); err != nil || issued.After(time.Now().UTC().Add(5*time.Minute)) {
|
|
return errors.New("marketplace issued_at is invalid")
|
|
}
|
|
}
|
|
if value := strings.TrimSpace(index.ExpiresAt); value != "" {
|
|
expires, err := time.Parse(time.RFC3339, value)
|
|
if err != nil {
|
|
return errors.New("marketplace expires_at is invalid")
|
|
}
|
|
if !expires.After(time.Now().UTC()) {
|
|
return errors.New("marketplace index has expired")
|
|
}
|
|
}
|
|
seen := map[string]struct{}{}
|
|
for _, entry := range index.Entries {
|
|
if !marketplaceIDPattern.MatchString(entry.PluginID) || len(entry.PluginID) > 160 {
|
|
return fmt.Errorf("invalid marketplace plugin_id: %s", entry.PluginID)
|
|
}
|
|
if strings.TrimSpace(entry.Name) == "" || len(entry.Name) > 160 {
|
|
return fmt.Errorf("invalid marketplace name for %s", entry.PluginID)
|
|
}
|
|
version := strings.TrimPrefix(strings.TrimSpace(entry.Version), "v")
|
|
if !marketplaceVersionPattern.MatchString(version) {
|
|
return fmt.Errorf("invalid marketplace version for %s", entry.PluginID)
|
|
}
|
|
if strings.TrimSpace(entry.ArchiveURL) == "" || len(entry.ArchiveURL) > 2048 || strings.ContainsAny(entry.ArchiveURL, "\x00\r\n") {
|
|
return fmt.Errorf("archive_url is required for %s", entry.PluginID)
|
|
}
|
|
if len(entry.Description) > 4096 || len(entry.ReleaseNotes) > 16384 {
|
|
return fmt.Errorf("marketplace description or release notes are too long for %s", entry.PluginID)
|
|
}
|
|
if !marketplaceSHA256Pattern.MatchString(strings.ToLower(strings.TrimSpace(entry.ArchiveSHA256))) {
|
|
return fmt.Errorf("invalid archive_sha256 for %s", entry.PluginID)
|
|
}
|
|
if entry.ArchiveSize < 0 || entry.ArchiveSize > maxPackageBytes {
|
|
return fmt.Errorf("invalid archive_size for %s", entry.PluginID)
|
|
}
|
|
if strings.TrimSpace(entry.PublisherKeyID) == "" || len(entry.PublisherKeyID) > 160 {
|
|
return fmt.Errorf("publisher_key_id is required for %s", entry.PluginID)
|
|
}
|
|
baseline := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(entry.CoreAPIBaseline), "sub2api-"), "v")
|
|
if !marketplaceVersionPattern.MatchString(baseline) {
|
|
return fmt.Errorf("invalid core_api_baseline for %s", entry.PluginID)
|
|
}
|
|
if len(entry.TestedCoreVersions) > 64 || len(entry.Capabilities) > 64 {
|
|
return fmt.Errorf("marketplace metadata contains too many values for %s", entry.PluginID)
|
|
}
|
|
if len(entry.TestedCoreVersions) == 0 {
|
|
return fmt.Errorf("tested_core_versions are required for %s", entry.PluginID)
|
|
}
|
|
for _, tested := range entry.TestedCoreVersions {
|
|
if !marketplaceVersionPattern.MatchString(strings.TrimPrefix(strings.TrimSpace(tested), "v")) {
|
|
return fmt.Errorf("invalid tested_core_versions for %s", entry.PluginID)
|
|
}
|
|
}
|
|
for _, capability := range entry.Capabilities {
|
|
if !marketplaceIDPattern.MatchString(capability) {
|
|
return fmt.Errorf("invalid capability for %s", entry.PluginID)
|
|
}
|
|
}
|
|
if len(entry.Capabilities) == 0 {
|
|
return fmt.Errorf("capabilities are required for %s", entry.PluginID)
|
|
}
|
|
key := entry.PluginID + "@" + version
|
|
if _, exists := seen[key]; exists {
|
|
return fmt.Errorf("duplicate marketplace entry: %s", key)
|
|
}
|
|
seen[key] = struct{}{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func marketplaceDialContext(allowLoopback bool) func(context.Context, string, string) (net.Conn, error) {
|
|
return func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
|
if err != nil {
|
|
return nil, errors.New("marketplace host DNS lookup failed")
|
|
}
|
|
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
|
var lastErr error
|
|
for _, ip := range ips {
|
|
if isForbiddenMarketplaceIP(ip) && !(allowLoopback && ip.IsLoopback()) {
|
|
lastErr = errors.New("marketplace host resolves to a private address")
|
|
continue
|
|
}
|
|
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
|
if dialErr == nil {
|
|
return conn, nil
|
|
}
|
|
lastErr = dialErr
|
|
}
|
|
if lastErr != nil {
|
|
return nil, lastErr
|
|
}
|
|
return nil, errors.New("marketplace host has no address")
|
|
}
|
|
}
|
|
|
|
func sameCapabilities(left, right []string) bool {
|
|
left = append([]string(nil), left...)
|
|
right = append([]string(nil), right...)
|
|
sort.Strings(left)
|
|
sort.Strings(right)
|
|
if len(left) != len(right) {
|
|
return false
|
|
}
|
|
for i := range left {
|
|
if left[i] != right[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func sameCoreVersions(left, right []string) bool {
|
|
normalize := func(values []string) []string {
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
out = append(out, strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(value), "sub2api-"), "v"))
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
return sameCapabilities(normalize(left), normalize(right))
|
|
}
|
|
|
|
func marketplaceEntryVersion(entry marketplaceEntry) string {
|
|
return strings.TrimPrefix(strings.TrimSpace(entry.Version), "v")
|
|
}
|
|
|
|
func (m marketplaceService) archiveBytes(ctx context.Context, entry marketplaceEntry, origin marketplaceOrigin) ([]byte, error) {
|
|
if origin.remoteURL != nil {
|
|
relative, err := url.Parse(strings.TrimSpace(entry.ArchiveURL))
|
|
if err != nil || relative.IsAbs() || relative.Host != "" || relative.User != nil || relative.RawQuery != "" || relative.Fragment != "" || relative.Path == "" || strings.HasPrefix(relative.Path, "/") || strings.Contains(relative.Path, "..") {
|
|
return nil, errors.New("marketplace archive URL must be a relative path without traversal")
|
|
}
|
|
archiveURL := origin.remoteURL.ResolveReference(relative)
|
|
if archiveURL.Scheme != "https" && !(m.allowLoopback && archiveURL.Scheme == "http" && isLoopbackHost(archiveURL.Hostname())) {
|
|
return nil, errors.New("marketplace archive URL must use HTTPS unless it targets loopback in development")
|
|
}
|
|
if !m.hostAllowed(archiveURL) {
|
|
return nil, errors.New("marketplace archive host is not allowlisted")
|
|
}
|
|
if err := m.validateRemoteHost(ctx, archiveURL); err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, archiveURL.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
res, err := m.httpClient().Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("marketplace archive returned HTTP %d", res.StatusCode)
|
|
}
|
|
if res.ContentLength > maxPackageBytes {
|
|
return nil, errors.New("marketplace archive exceeds package size limit")
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(res.Body, maxPackageBytes+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) > maxPackageBytes {
|
|
return nil, errors.New("marketplace archive exceeds package size limit")
|
|
}
|
|
return verifyMarketplaceArchive(entry, data)
|
|
}
|
|
archivePath, err := localMarketplaceArchivePath(origin.localPath, entry.ArchiveURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
file, err := os.Open(archivePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if info.Size() > maxPackageBytes {
|
|
return nil, errors.New("marketplace archive exceeds package size limit")
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(file, maxPackageBytes+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(data) > maxPackageBytes {
|
|
return nil, errors.New("marketplace archive exceeds package size limit")
|
|
}
|
|
return verifyMarketplaceArchive(entry, data)
|
|
}
|
|
|
|
func localMarketplaceArchivePath(indexPath, raw string) (string, error) {
|
|
if indexPath == "" {
|
|
return "", errors.New("local marketplace index is not configured")
|
|
}
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return "", errors.New("local marketplace archive URL must be a relative path")
|
|
}
|
|
base := filepath.Dir(indexPath)
|
|
candidate := filepath.Clean(filepath.Join(base, filepath.FromSlash(parsed.Path)))
|
|
rel, err := filepath.Rel(base, candidate)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
|
return "", errors.New("local marketplace archive path escapes the index directory")
|
|
}
|
|
baseResolved, err := filepath.EvalSymlinks(base)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(candidate)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
rel, err = filepath.Rel(baseResolved, resolved)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
|
return "", errors.New("local marketplace archive symlink escapes the index directory")
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
func verifyMarketplaceArchive(entry marketplaceEntry, archive []byte) ([]byte, error) {
|
|
if entry.ArchiveSize > 0 && int64(len(archive)) != entry.ArchiveSize {
|
|
return nil, errors.New("marketplace archive size mismatch")
|
|
}
|
|
sum := sha256.Sum256(archive)
|
|
hash := hex.EncodeToString(sum[:])
|
|
if !strings.EqualFold(hash, strings.TrimSpace(entry.ArchiveSHA256)) {
|
|
return nil, errors.New("marketplace archive hash mismatch")
|
|
}
|
|
return archive, nil
|
|
}
|