Integrate validated catalog loading (T-201)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user