package catalog import ( "bytes" "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 { _, err := decodeCanonicalSignature(value) if err != nil { return fmt.Errorf("must be canonical padded Base64 for 64 bytes: %v", err) } 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 }