Files
soft_quay/core/catalog/parser.go
T

294 lines
8.2 KiB
Go
Raw Normal View History

2026-07-16 16:52:46 +08:00
package catalog
import (
"bytes"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"regexp"
"strings"
"time"
2026-07-16 17:00:06 +08:00
"softbox.local/core/domain"
2026-07-16 23:56:19 +08:00
"softbox.local/core/internal/safepath"
2026-07-16 16:52:46 +08:00
)
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")
}
2026-07-16 17:00:06 +08:00
if _, err := domain.ParseSemVer(manifest.MinBoxVersion); err != nil {
2026-07-16 16:52:46 +08:00
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")
}
2026-07-16 17:00:06 +08:00
if _, err := domain.ParseSemVer(app.Version); err != nil {
2026-07-16 16:52:46 +08:00
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")
}
2026-07-16 23:56:19 +08:00
if err := safepath.ValidateRelative(app.EntryEXE); err != nil {
return invalidField(
"entry_exe",
"must be a safe Windows relative path: %v",
err,
)
2026-07-16 16:52:46 +08:00
}
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
}