Integrate validated catalog loading (T-201)

This commit is contained in:
ila
2026-07-16 16:52:46 +08:00
parent 9da72caa01
commit 2e21c9f327
20 changed files with 1450 additions and 40 deletions
+48
View File
@@ -0,0 +1,48 @@
package catalog
import "context"
// Client composes signed loading, protocol validation and target filtering.
type Client struct {
loader *Loader
parser Parser
target Target
}
// NewClient creates the formal Catalog loading path. Protocol validation is
// installed on Loader so invalid remote data cannot replace the usable cache.
func NewClient(
verifier Verifier,
fetcher Fetcher,
cache Cache,
target Target,
) *Client {
parser := Parser{ExpectedChannel: target.Channel}
return &Client{
loader: NewLoader(verifier, fetcher, cache, parser),
parser: parser,
target: target,
}
}
// Load returns a verified, validated and target-filtered Catalog.
func (client *Client) Load(ctx context.Context) (Catalog, error) {
result, err := client.loader.Load(ctx)
if err != nil {
return Catalog{}, err
}
manifest, err := client.parser.Parse(result.Document)
if err != nil {
return Catalog{}, err
}
entries, err := Filter(manifest, client.target)
if err != nil {
return Catalog{}, err
}
return Catalog{
Manifest: manifest,
Entries: entries,
Source: result.Source,
Warning: result.Warning,
}, nil
}
+60
View File
@@ -0,0 +1,60 @@
package catalog
import (
"context"
"encoding/json"
"errors"
"testing"
)
func TestClientValidatesBeforeReplacingCache(t *testing.T) {
publicKey, privateKey := catalogTestKey()
verifier, err := NewVerifier(publicKey)
if err != nil {
t.Fatalf("NewVerifier() error = %v", err)
}
validPayload := readCatalogFixture(t, "manifest-valid-payload.json")
validDocument, _ := signCatalogPayload(t, validPayload, privateKey)
cache := &memoryCache{document: append([]byte(nil), validDocument...)}
var invalidPayload map[string]any
if err := json.Unmarshal(validPayload, &invalidPayload); err != nil {
t.Fatalf("decode payload: %v", err)
}
invalidPayload["channel"] = "win7"
encodedInvalid, err := json.Marshal(invalidPayload)
if err != nil {
t.Fatalf("encode invalid payload: %v", err)
}
invalidDocument, _ := signCatalogPayload(t, encodedInvalid, privateKey)
client := NewClient(
verifier,
FetchFunc(func(context.Context) ([]byte, error) {
return invalidDocument, nil
}),
cache,
Target{
Channel: ChannelModern,
OS: Windows10,
Architecture: ArchitectureAMD64,
},
)
result, err := client.Load(context.Background())
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if result.Source != SourceCache {
t.Fatalf("Source = %q, want %q", result.Source, SourceCache)
}
if !errors.Is(result.Warning, ErrChannelMismatch) {
t.Fatalf("Warning = %v, want %v", result.Warning, ErrChannelMismatch)
}
if cache.storeCalls != 0 {
t.Fatalf("Store() calls = %d, want 0", cache.storeCalls)
}
if len(result.Entries) != 1 || result.Entries[0].App.ID != "json-parser" {
t.Fatalf("Entries = %#v", result.Entries)
}
}
+58
View File
@@ -0,0 +1,58 @@
package catalog
import "fmt"
// Filter returns visible entries for target while retaining incompatible apps
// with a stable reason and no package action.
func Filter(manifest Manifest, target Target) ([]Entry, error) {
if !target.Channel.valid() ||
!target.OS.valid() ||
!target.Architecture.valid() {
return nil, fmt.Errorf("%w: %+v", ErrUnsupportedTarget, target)
}
if manifest.Channel != target.Channel {
return nil, fmt.Errorf(
"%w: got %q, want %q",
ErrChannelMismatch,
manifest.Channel,
target.Channel,
)
}
entries := make([]Entry, 0, len(manifest.Apps))
for _, app := range manifest.Apps {
if app.Status == CatalogStatusHidden {
continue
}
entry := Entry{App: app}
if app.Status == CatalogStatusDeprecated {
entry.Reason = ReasonDeprecated
entries = append(entries, entry)
continue
}
if !supportsOS(target.OS, app.MinOS) {
entry.Reason = ReasonMinimumOS
entries = append(entries, entry)
continue
}
publishedPackage, ok := app.Packages[target.Architecture]
if !ok {
entry.Reason = ReasonArchitecture
entries = append(entries, entry)
continue
}
entry.Package = &publishedPackage
entry.Installable = true
entries = append(entries, entry)
}
return entries, nil
}
func supportsOS(target, minimum WindowsRelease) bool {
ranks := map[WindowsRelease]int{
Windows7SP1: 7,
Windows10: 10,
Windows11: 11,
}
return ranks[target] >= ranks[minimum]
}
+80
View File
@@ -0,0 +1,80 @@
package catalog
import "testing"
func TestFilterHandlesStatusOSAndArchitecture(t *testing.T) {
manifest := validManifestForTest()
active := manifest.Apps[0]
deprecated := active
deprecated.ID = "deprecated-app"
deprecated.Status = CatalogStatusDeprecated
hidden := active
hidden.ID = "hidden-app"
hidden.Status = CatalogStatusHidden
newOS := active
newOS.ID = "windows-11-app"
newOS.MinOS = Windows11
wrongArchitecture := active
wrongArchitecture.ID = "x86-app"
wrongArchitecture.Architectures = []Architecture{Architecture386}
wrongArchitecture.Packages = map[Architecture]Package{
Architecture386: active.Packages[ArchitectureAMD64],
}
manifest.Apps = []App{active, deprecated, hidden, newOS, wrongArchitecture}
entries, err := Filter(manifest, Target{
Channel: ChannelModern,
OS: Windows10,
Architecture: ArchitectureAMD64,
})
if err != nil {
t.Fatalf("Filter() error = %v", err)
}
if len(entries) != 4 {
t.Fatalf("len(entries) = %d, want 4", len(entries))
}
assertEntry := func(index int, id string, installable bool, reason AvailabilityReason) {
t.Helper()
entry := entries[index]
if entry.App.ID != id ||
entry.Installable != installable ||
entry.Reason != reason {
t.Fatalf(
"entries[%d] = {%q %t %q}, want {%q %t %q}",
index,
entry.App.ID,
entry.Installable,
entry.Reason,
id,
installable,
reason,
)
}
}
assertEntry(0, "json-parser", true, ReasonNone)
assertEntry(1, "deprecated-app", false, ReasonDeprecated)
assertEntry(2, "windows-11-app", false, ReasonMinimumOS)
assertEntry(3, "x86-app", false, ReasonArchitecture)
}
func TestFilterAllowsWin7CompatibleAppOnModernWindows(t *testing.T) {
manifest := validManifestForTest()
manifest.Apps[0].MinOS = Windows7SP1
entries, err := Filter(manifest, Target{
Channel: ChannelModern,
OS: Windows11,
Architecture: ArchitectureAMD64,
})
if err != nil {
t.Fatalf("Filter() error = %v", err)
}
if len(entries) != 1 || !entries[0].Installable {
t.Fatalf("entries = %#v", entries)
}
}
+105
View File
@@ -0,0 +1,105 @@
package catalog
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
const DefaultMaxManifestBytes int64 = 8 << 20
var (
ErrInsecureCatalogURL = errors.New("catalog URL must use HTTPS")
ErrCatalogHTTPStatus = errors.New("catalog HTTP status is not successful")
ErrCatalogTooLarge = errors.New("catalog response exceeds size limit")
)
// HTTPFetcher obtains a Catalog document over HTTPS with a bounded response.
type HTTPFetcher struct {
URL string
Client *http.Client
MaxBytes int64
}
// Fetch implements Fetcher.
func (fetcher HTTPFetcher) Fetch(ctx context.Context) ([]byte, error) {
parsedURL, err := url.Parse(fetcher.URL)
if err != nil {
return nil, fmt.Errorf("parse catalog URL: %w", err)
}
if err := validateFetchURL(parsedURL); err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("create catalog request: %w", err)
}
request.Header.Set("Accept", "application/json")
client := fetcher.Client
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
clientCopy := *client
previousRedirectCheck := client.CheckRedirect
clientCopy.CheckRedirect = func(request *http.Request, via []*http.Request) error {
if err := validateFetchURL(request.URL); err != nil {
return err
}
if previousRedirectCheck != nil {
return previousRedirectCheck(request, via)
}
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
return nil
}
response, err := clientCopy.Do(request)
if err != nil {
return nil, fmt.Errorf("fetch catalog: %w", err)
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("%w: %s", ErrCatalogHTTPStatus, response.Status)
}
maxBytes := fetcher.MaxBytes
if maxBytes <= 0 {
maxBytes = DefaultMaxManifestBytes
}
if response.ContentLength > maxBytes {
return nil, fmt.Errorf(
"%w: content-length %d, limit %d",
ErrCatalogTooLarge,
response.ContentLength,
maxBytes,
)
}
document, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1))
if err != nil {
return nil, fmt.Errorf("read catalog response: %w", err)
}
if int64(len(document)) > maxBytes {
return nil, fmt.Errorf("%w: limit %d", ErrCatalogTooLarge, maxBytes)
}
return document, nil
}
func validateFetchURL(parsedURL *url.URL) error {
if parsedURL == nil ||
parsedURL.Scheme != "https" ||
parsedURL.Host == "" ||
parsedURL.User != nil {
return ErrInsecureCatalogURL
}
if parsedURL.Fragment != "" {
return fmt.Errorf("%w: fragments are not allowed", ErrInsecureCatalogURL)
}
return nil
}
+82
View File
@@ -0,0 +1,82 @@
package catalog
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func TestHTTPFetcherRequiresHTTPSAndBoundsResponse(t *testing.T) {
t.Run("insecure URL", func(t *testing.T) {
_, err := (HTTPFetcher{URL: "http://example.invalid/manifest.json"}).
Fetch(context.Background())
if !errors.Is(err, ErrInsecureCatalogURL) {
t.Fatalf("Fetch() error = %v, want %v", err, ErrInsecureCatalogURL)
}
})
t.Run("successful HTTPS", func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
if request.Header.Get("Accept") != "application/json" {
t.Errorf("Accept = %q", request.Header.Get("Accept"))
}
_, _ = writer.Write([]byte(`{"schema_version":1}`))
}))
defer server.Close()
document, err := (HTTPFetcher{
URL: server.URL,
Client: server.Client(),
MaxBytes: 64,
}).Fetch(context.Background())
if err != nil {
t.Fatalf("Fetch() error = %v", err)
}
if string(document) != `{"schema_version":1}` {
t.Fatalf("document = %q", document)
}
})
t.Run("too large", func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
writer.Header().Set("Content-Length", "6")
_, _ = writer.Write([]byte("123456"))
}))
defer server.Close()
_, err := (HTTPFetcher{
URL: server.URL,
Client: server.Client(),
MaxBytes: 5,
}).Fetch(context.Background())
if !errors.Is(err, ErrCatalogTooLarge) {
t.Fatalf("Fetch() error = %v, want %v", err, ErrCatalogTooLarge)
}
})
t.Run("HTTP error", func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(
writer http.ResponseWriter,
_ *http.Request,
) {
http.Error(writer, "unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()
_, err := (HTTPFetcher{
URL: server.URL,
Client: server.Client(),
}).Fetch(context.Background())
if !errors.Is(err, ErrCatalogHTTPStatus) {
t.Fatalf("Fetch() error = %v, want %v", err, ErrCatalogHTTPStatus)
}
})
}
+56 -18
View File
@@ -26,6 +26,19 @@ type Cache interface {
Store([]byte) error
}
// DocumentValidator rejects signed documents that this client cannot consume.
// Validators run before a remote document can replace the last valid cache.
type DocumentValidator interface {
Validate(VerifiedDocument) error
}
// DocumentValidatorFunc adapts a function to DocumentValidator.
type DocumentValidatorFunc func(VerifiedDocument) error
func (function DocumentValidatorFunc) Validate(document VerifiedDocument) error {
return function(document)
}
// LoadSource describes where a verified result came from.
type LoadSource string
@@ -57,16 +70,23 @@ func (err *LoadError) Unwrap() error {
// Loader verifies remote data before storing it and re-verifies cache fallback.
type Loader struct {
verifier Verifier
fetcher Fetcher
cache Cache
verifier Verifier
fetcher Fetcher
cache Cache
validators []DocumentValidator
}
func NewLoader(verifier Verifier, fetcher Fetcher, cache Cache) *Loader {
func NewLoader(
verifier Verifier,
fetcher Fetcher,
cache Cache,
validators ...DocumentValidator,
) *Loader {
return &Loader{
verifier: verifier,
fetcher: fetcher,
cache: cache,
verifier: verifier,
fetcher: fetcher,
cache: cache,
validators: append([]DocumentValidator(nil), validators...),
}
}
@@ -76,12 +96,15 @@ func (loader *Loader) Load(ctx context.Context) (LoadResult, error) {
if refreshErr == nil {
verified, verifyErr := loader.verifier.Verify(remoteBytes)
if verifyErr == nil {
storeErr := loader.cache.Store(verified.Bytes)
return LoadResult{
Document: verified,
Source: SourceRemote,
Warning: storeErr,
}, nil
verifyErr = loader.validate(verified)
if verifyErr == nil {
storeErr := loader.cache.Store(verified.Bytes)
return LoadResult{
Document: verified,
Source: SourceRemote,
Warning: storeErr,
}, nil
}
}
refreshErr = verifyErr
}
@@ -91,11 +114,14 @@ func (loader *Loader) Load(ctx context.Context) (LoadResult, error) {
var verified VerifiedDocument
verified, cacheErr = loader.verifier.Verify(cachedBytes)
if cacheErr == nil {
return LoadResult{
Document: verified,
Source: SourceCache,
Warning: refreshErr,
}, nil
cacheErr = loader.validate(verified)
if cacheErr == nil {
return LoadResult{
Document: verified,
Source: SourceCache,
Warning: refreshErr,
}, nil
}
}
}
@@ -104,3 +130,15 @@ func (loader *Loader) Load(ctx context.Context) (LoadResult, error) {
Cache: cacheErr,
}
}
func (loader *Loader) validate(document VerifiedDocument) error {
for _, validator := range loader.validators {
if validator == nil {
continue
}
if err := validator.Validate(document); err != nil {
return err
}
}
return nil
}
+113
View File
@@ -0,0 +1,113 @@
package catalog
// ManifestChannel separates modern and Win7 delivery tracks.
type ManifestChannel string
const (
ChannelModern ManifestChannel = "modern"
ChannelWin7 ManifestChannel = "win7"
)
// ReleaseChannel is the app release track supported by the MVP.
type ReleaseChannel string
const (
ReleaseStable ReleaseChannel = "stable"
)
// AppCatalogStatus controls catalog visibility and installability.
type AppCatalogStatus string
const (
CatalogStatusActive AppCatalogStatus = "active"
CatalogStatusDeprecated AppCatalogStatus = "deprecated"
CatalogStatusHidden AppCatalogStatus = "hidden"
)
// Architecture identifies a Windows package architecture.
type Architecture string
const (
Architecture386 Architecture = "386"
ArchitectureAMD64 Architecture = "amd64"
)
// WindowsRelease is an ordered minimum Windows release identifier.
type WindowsRelease string
const (
Windows7SP1 WindowsRelease = "windows-7-sp1"
Windows10 WindowsRelease = "windows-10"
Windows11 WindowsRelease = "windows-11"
)
// Manifest is the signed Catalog protocol v1 document.
type Manifest struct {
SchemaVersion int `json:"schema_version"`
Channel ManifestChannel `json:"channel"`
GeneratedAt string `json:"generated_at"`
MinBoxVersion string `json:"min_box_version"`
Apps []App `json:"apps"`
Signature string `json:"signature"`
}
// App is one published product in a Manifest.
type App struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Channel ReleaseChannel `json:"channel"`
Status AppCatalogStatus `json:"status"`
Category string `json:"category"`
Tags []string `json:"tags"`
Icon string `json:"icon,omitempty"`
Homepage string `json:"homepage,omitempty"`
Tutorial string `json:"tutorial,omitempty"`
MinOS WindowsRelease `json:"min_os"`
Architectures []Architecture `json:"architectures"`
EntryEXE string `json:"entry_exe"`
RequiresAdmin bool `json:"requires_admin"`
Packages map[Architecture]Package `json:"packages"`
}
// Package describes one downloadable ZIP artifact.
type Package struct {
URL string `json:"url"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Signature string `json:"signature"`
}
// Target describes the client build and operating system consuming a Catalog.
type Target struct {
Channel ManifestChannel
OS WindowsRelease
Architecture Architecture
}
// AvailabilityReason is stable data for UI localization and action gating.
type AvailabilityReason string
const (
ReasonNone AvailabilityReason = ""
ReasonDeprecated AvailabilityReason = "deprecated"
ReasonMinimumOS AvailabilityReason = "minimum_os"
ReasonArchitecture AvailabilityReason = "architecture"
)
// Entry is a visible app after target filtering.
type Entry struct {
App App
Package *Package
Installable bool
Reason AvailabilityReason
}
// Catalog is a verified, validated and target-filtered Manifest.
type Catalog struct {
Manifest Manifest
Entries []Entry
Source LoadSource
Warning error
}
+300
View File
@@ -0,0 +1,300 @@
package catalog
import (
"bytes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"path"
"regexp"
"strings"
"time"
)
var (
ErrInvalidManifest = errors.New("invalid catalog manifest")
ErrChannelMismatch = errors.New("catalog channel mismatch")
ErrUnsupportedTarget = errors.New("unsupported catalog target")
)
var (
appIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
semVerPattern = 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-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`)
sha256Pattern = regexp.MustCompile(`^[0-9A-Fa-f]{64}$`)
iconRefPattern = regexp.MustCompile(`^sha256:[0-9A-Fa-f]{64}$`)
)
// Parser validates the protocol shape for one delivery channel.
type Parser struct {
ExpectedChannel ManifestChannel
}
// Validate implements DocumentValidator.
func (parser Parser) Validate(document VerifiedDocument) error {
_, err := parser.Parse(document)
return err
}
// Parse strictly decodes a previously verified Manifest.
func (parser Parser) Parse(document VerifiedDocument) (Manifest, error) {
if !parser.ExpectedChannel.valid() {
return Manifest{}, fmt.Errorf(
"%w: channel %q",
ErrUnsupportedTarget,
parser.ExpectedChannel,
)
}
decoder := json.NewDecoder(bytes.NewReader(document.Bytes))
decoder.DisallowUnknownFields()
decoder.UseNumber()
var manifest Manifest
if err := decoder.Decode(&manifest); err != nil {
return Manifest{}, fmt.Errorf("%w: decode: %v", ErrInvalidManifest, err)
}
if err := consumeEOF(decoder); err != nil {
return Manifest{}, err
}
if err := validateManifest(manifest, parser.ExpectedChannel); err != nil {
return Manifest{}, err
}
return manifest, nil
}
func consumeEOF(decoder *json.Decoder) error {
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return fmt.Errorf("%w: trailing JSON value", ErrInvalidManifest)
}
return fmt.Errorf("%w: trailing data: %v", ErrInvalidManifest, err)
}
return nil
}
func validateManifest(manifest Manifest, expectedChannel ManifestChannel) error {
if manifest.SchemaVersion != 1 {
return invalidField("schema_version", "must be 1")
}
if !manifest.Channel.valid() {
return invalidField("channel", "unsupported value %q", manifest.Channel)
}
if manifest.Channel != expectedChannel {
return fmt.Errorf(
"%w: got %q, want %q",
ErrChannelMismatch,
manifest.Channel,
expectedChannel,
)
}
generatedAt, err := time.Parse(time.RFC3339, manifest.GeneratedAt)
_, offset := generatedAt.Zone()
if err != nil || offset != 0 {
return invalidField("generated_at", "must be an RFC3339 UTC timestamp")
}
if !semVerPattern.MatchString(manifest.MinBoxVersion) {
return invalidField("min_box_version", "must be SemVer")
}
if err := validateSignature(manifest.Signature); err != nil {
return invalidField("signature", "%v", err)
}
seenIDs := make(map[string]struct{}, len(manifest.Apps))
for index, app := range manifest.Apps {
if err := validateApp(app); err != nil {
return fmt.Errorf("%w: apps[%d]: %v", ErrInvalidManifest, index, err)
}
if _, exists := seenIDs[app.ID]; exists {
return fmt.Errorf("%w: duplicate app id %q", ErrInvalidManifest, app.ID)
}
seenIDs[app.ID] = struct{}{}
}
return nil
}
func validateApp(app App) error {
if !appIDPattern.MatchString(app.ID) {
return invalidField("id", "must match ^[a-z0-9-]+$")
}
if strings.TrimSpace(app.Name) == "" {
return invalidField("name", "must not be empty")
}
if strings.TrimSpace(app.Description) == "" {
return invalidField("description", "must not be empty")
}
if !semVerPattern.MatchString(app.Version) {
return invalidField("version", "must be SemVer")
}
if app.Channel != ReleaseStable {
return invalidField("channel", "unsupported value %q", app.Channel)
}
if !app.Status.valid() {
return invalidField("status", "unsupported value %q", app.Status)
}
if strings.TrimSpace(app.Category) == "" {
return invalidField("category", "must not be empty")
}
if len(app.Tags) == 0 {
return invalidField("tags", "must contain at least one tag")
}
for _, tag := range app.Tags {
if strings.TrimSpace(tag) == "" {
return invalidField("tags", "must not contain empty values")
}
}
if app.Icon != "" && !iconRefPattern.MatchString(app.Icon) {
return invalidField("icon", "must be sha256:<64 hexadecimal characters>")
}
if err := validateOptionalHTTPSURL("homepage", app.Homepage); err != nil {
return err
}
if err := validateOptionalHTTPSURL("tutorial", app.Tutorial); err != nil {
return err
}
if !app.MinOS.valid() {
return invalidField("min_os", "unsupported value %q", app.MinOS)
}
if len(app.Architectures) == 0 {
return invalidField("architectures", "must not be empty")
}
if !validSafeRelativePath(app.EntryEXE) {
return invalidField("entry_exe", "must be a safe relative path")
}
if len(app.Packages) == 0 {
return invalidField("packages", "must not be empty")
}
architectures := make(map[Architecture]struct{}, len(app.Architectures))
for _, architecture := range app.Architectures {
if !architecture.valid() {
return invalidField("architectures", "unsupported value %q", architecture)
}
if _, exists := architectures[architecture]; exists {
return invalidField("architectures", "duplicate value %q", architecture)
}
architectures[architecture] = struct{}{}
}
for architecture := range architectures {
publishedPackage, exists := app.Packages[architecture]
if !exists {
return invalidField("packages", "missing %q package", architecture)
}
if err := validatePackage(architecture, publishedPackage); err != nil {
return err
}
}
for architecture := range app.Packages {
if _, exists := architectures[architecture]; !exists {
return invalidField(
"packages",
"package %q is absent from architectures",
architecture,
)
}
}
return nil
}
func validatePackage(architecture Architecture, publishedPackage Package) error {
if !architecture.valid() {
return invalidField("packages", "unsupported key %q", architecture)
}
if err := validateHTTPSURL(publishedPackage.URL); err != nil {
return invalidField("packages."+string(architecture)+".url", "%v", err)
}
if publishedPackage.Size <= 0 {
return invalidField("packages."+string(architecture)+".size", "must be positive")
}
if !sha256Pattern.MatchString(publishedPackage.SHA256) {
return invalidField(
"packages."+string(architecture)+".sha256",
"must contain 64 hexadecimal characters",
)
}
if _, err := hex.DecodeString(publishedPackage.SHA256); err != nil {
return invalidField("packages."+string(architecture)+".sha256", "%v", err)
}
if err := validateSignature(publishedPackage.Signature); err != nil {
return invalidField("packages."+string(architecture)+".signature", "%v", err)
}
return nil
}
func validateSignature(value string) error {
signature, err := base64.StdEncoding.Strict().DecodeString(value)
if err != nil {
return fmt.Errorf("must be strict Base64: %v", err)
}
if len(signature) != 64 {
return fmt.Errorf("must decode to 64 bytes")
}
return nil
}
func validateOptionalHTTPSURL(fieldName, value string) error {
if value == "" {
return nil
}
if err := validateHTTPSURL(value); err != nil {
return invalidField(fieldName, "%v", err)
}
return nil
}
func validateHTTPSURL(value string) error {
parsed, err := url.Parse(value)
if err != nil {
return fmt.Errorf("invalid URL: %v", err)
}
if parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
return errors.New("must be an absolute HTTPS URL without user information")
}
if parsed.Fragment != "" {
return errors.New("must not contain a fragment")
}
return nil
}
func validSafeRelativePath(value string) bool {
if value == "" || strings.Contains(value, `\`) || strings.Contains(value, ":") {
return false
}
cleaned := path.Clean(value)
return cleaned == value &&
cleaned != "." &&
!strings.HasPrefix(cleaned, "/") &&
cleaned != ".." &&
!strings.HasPrefix(cleaned, "../")
}
func invalidField(field, format string, values ...any) error {
return fmt.Errorf(
"%w: %s: %s",
ErrInvalidManifest,
field,
fmt.Sprintf(format, values...),
)
}
func (channel ManifestChannel) valid() bool {
return channel == ChannelModern || channel == ChannelWin7
}
func (status AppCatalogStatus) valid() bool {
return status == CatalogStatusActive ||
status == CatalogStatusDeprecated ||
status == CatalogStatusHidden
}
func (architecture Architecture) valid() bool {
return architecture == Architecture386 || architecture == ArchitectureAMD64
}
func (release WindowsRelease) valid() bool {
return release == Windows7SP1 || release == Windows10 || release == Windows11
}
+151
View File
@@ -0,0 +1,151 @@
package catalog
import (
"encoding/json"
"errors"
"testing"
)
func TestParserAcceptsValidSignedManifest(t *testing.T) {
verifier, document := signedFixture(t, "manifest-valid-payload.json")
verified, err := verifier.Verify(document)
if err != nil {
t.Fatalf("Verify() error = %v", err)
}
manifest, err := (Parser{ExpectedChannel: ChannelModern}).Parse(verified)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if len(manifest.Apps) != 1 || manifest.Apps[0].ID != "json-parser" {
t.Fatalf("Apps = %#v", manifest.Apps)
}
}
func TestParserRejectsWrongChannelAndUnknownFields(t *testing.T) {
verifier, document := signedFixture(t, "manifest-valid-payload.json")
verified, err := verifier.Verify(document)
if err != nil {
t.Fatalf("Verify() error = %v", err)
}
_, err = (Parser{ExpectedChannel: ChannelWin7}).Parse(verified)
if !errors.Is(err, ErrChannelMismatch) {
t.Fatalf("Parse() error = %v, want %v", err, ErrChannelMismatch)
}
var payload map[string]any
if err := json.Unmarshal(readCatalogFixture(t, "manifest-valid-payload.json"), &payload); err != nil {
t.Fatalf("decode fixture: %v", err)
}
payload["unexpected"] = true
mutatedPayload, err := json.Marshal(payload)
if err != nil {
t.Fatalf("encode fixture: %v", err)
}
_, privateKey := catalogTestKey()
mutatedDocument, _ := signCatalogPayload(t, mutatedPayload, privateKey)
verified, err = verifier.Verify(mutatedDocument)
if err != nil {
t.Fatalf("Verify(mutated) error = %v", err)
}
_, err = (Parser{ExpectedChannel: ChannelModern}).Parse(verified)
if !errors.Is(err, ErrInvalidManifest) {
t.Fatalf("Parse(mutated) error = %v, want %v", err, ErrInvalidManifest)
}
}
func TestParserRejectsDuplicateAppIDAndPackageMismatch(t *testing.T) {
base := validManifestForTest()
base.Apps = append(base.Apps, base.Apps[0])
_, err := parseSignedManifestForTest(t, base, ChannelModern)
if !errors.Is(err, ErrInvalidManifest) {
t.Fatalf("duplicate Parse() error = %v, want %v", err, ErrInvalidManifest)
}
base = validManifestForTest()
delete(base.Apps[0].Packages, ArchitectureAMD64)
_, err = parseSignedManifestForTest(t, base, ChannelModern)
if !errors.Is(err, ErrInvalidManifest) {
t.Fatalf("package Parse() error = %v, want %v", err, ErrInvalidManifest)
}
}
func signedFixture(t *testing.T, name string) (Verifier, []byte) {
t.Helper()
publicKey, privateKey := catalogTestKey()
verifier, err := NewVerifier(publicKey)
if err != nil {
t.Fatalf("NewVerifier() error = %v", err)
}
document, _ := signCatalogPayload(t, readCatalogFixture(t, name), privateKey)
return verifier, document
}
func validManifestForTest() Manifest {
return Manifest{
SchemaVersion: 1,
Channel: ChannelModern,
GeneratedAt: "2026-07-16T00:00:00Z",
MinBoxVersion: "1.0.0",
Apps: []App{
{
ID: "json-parser",
Name: "JSON解析工具",
Description: "测试目录项",
Version: "1.2.0",
Channel: ReleaseStable,
Status: CatalogStatusActive,
Category: "开发工具",
Tags: []string{"工具", "JSON"},
Icon: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
Homepage: "https://example.invalid/json-parser",
Tutorial: "https://example.invalid/json-parser/tutorial",
MinOS: Windows10,
Architectures: []Architecture{ArchitectureAMD64},
EntryEXE: "JsonParser.exe",
Packages: map[Architecture]Package{
ArchitectureAMD64: {
URL: "https://download.invalid/json-parser.zip",
Size: 42,
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
Signature: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
},
},
},
},
}
}
func parseSignedManifestForTest(
t *testing.T,
manifest Manifest,
expectedChannel ManifestChannel,
) (Manifest, error) {
t.Helper()
manifest.Signature = ""
payloadValue := map[string]any{}
encodedManifest, err := json.Marshal(manifest)
if err != nil {
t.Fatalf("encode manifest: %v", err)
}
if err := json.Unmarshal(encodedManifest, &payloadValue); err != nil {
t.Fatalf("decode manifest: %v", err)
}
delete(payloadValue, "signature")
payload, err := json.Marshal(payloadValue)
if err != nil {
t.Fatalf("encode payload: %v", err)
}
publicKey, privateKey := catalogTestKey()
document, _ := signCatalogPayload(t, payload, privateKey)
verifier, err := NewVerifier(publicKey)
if err != nil {
t.Fatalf("NewVerifier() error = %v", err)
}
verified, err := verifier.Verify(document)
if err != nil {
t.Fatalf("Verify() error = %v", err)
}
return (Parser{ExpectedChannel: expectedChannel}).Parse(verified)
}
+29
View File
@@ -0,0 +1,29 @@
package catalog
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestProtocolSchemasAreValidJSONObjects(t *testing.T) {
for _, name := range []string{"manifest.schema.json", "app.schema.json"} {
t.Run(name, func(t *testing.T) {
document, err := os.ReadFile(filepath.Join("..", "..", "schemas", name))
if err != nil {
t.Fatalf("read schema: %v", err)
}
var schema map[string]any
if err := json.Unmarshal(document, &schema); err != nil {
t.Fatalf("decode schema: %v", err)
}
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" {
t.Fatalf("$schema = %v", schema["$schema"])
}
if schema["type"] != "object" {
t.Fatalf("type = %v", schema["type"])
}
})
}
}