294 lines
8.2 KiB
Go
294 lines
8.2 KiB
Go
package catalog
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"softbox.local/core/domain"
|
|
"softbox.local/core/internal/safepath"
|
|
)
|
|
|
|
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-]+$`)
|
|
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 _, err := domain.ParseSemVer(manifest.MinBoxVersion); err != nil {
|
|
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 _, err := domain.ParseSemVer(app.Version); err != nil {
|
|
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 err := safepath.ValidateRelative(app.EntryEXE); err != nil {
|
|
return invalidField(
|
|
"entry_exe",
|
|
"must be a safe Windows relative path: %v",
|
|
err,
|
|
)
|
|
}
|
|
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 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
|
|
}
|