646 lines
21 KiB
Go
646 lines
21 KiB
Go
// Package catalog validates and loads immutable game-template snapshots.
|
|
package catalog
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/specs"
|
|
"github.com/dlclark/regexp2"
|
|
"github.com/santhosh-tekuri/jsonschema/v6"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const templateSchemaURL = "https://dogama.dev/schemas/template-v1.json"
|
|
const moduleManifestSchemaURL = "https://dogama.dev/schemas/module-manifest-v1.json"
|
|
const maxModuleBundleBytes = 16 << 20
|
|
|
|
// ValidationIssue points to one invalid field without exposing input secrets.
|
|
type ValidationIssue struct {
|
|
Path string `json:"path"`
|
|
Line int `json:"line,omitempty"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// ValidationErrors groups field-specific template errors.
|
|
type ValidationErrors struct{ Issues []ValidationIssue }
|
|
|
|
func (e *ValidationErrors) Error() string {
|
|
return fmt.Sprintf("template validation failed with %d issue(s)", len(e.Issues))
|
|
}
|
|
|
|
// Template is the validated subset needed to build a deployment preview.
|
|
type Template struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
ID string `json:"id"`
|
|
Version string `json:"version"`
|
|
Source struct {
|
|
Type string `json:"type"`
|
|
} `json:"source"`
|
|
Game struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Artwork struct {
|
|
Logo string `json:"logo"`
|
|
Image string `json:"image"`
|
|
Poster string `json:"poster"`
|
|
Attribution string `json:"attribution"`
|
|
} `json:"artwork"`
|
|
} `json:"game"`
|
|
Requirements struct {
|
|
Minimum Resources `json:"minimum"`
|
|
Recommended Resources `json:"recommended"`
|
|
} `json:"requirements"`
|
|
Container struct {
|
|
Image string `json:"image"`
|
|
Tag string `json:"tag"`
|
|
Entrypoint []string `json:"entrypoint,omitempty"`
|
|
UserMode string `json:"user_mode,omitempty"`
|
|
Arguments []string `json:"arguments,omitempty"`
|
|
Environment map[string]string `json:"environment,omitempty"`
|
|
StopTimeoutSeconds int `json:"stop_timeout_seconds"`
|
|
Ports []Port `json:"ports"`
|
|
Assets []struct {
|
|
Source string `json:"source"`
|
|
Destination string `json:"destination"`
|
|
ReadOnly bool `json:"read_only"`
|
|
} `json:"assets"`
|
|
} `json:"container"`
|
|
Storage struct {
|
|
Mounts []Mount `json:"mounts"`
|
|
} `json:"storage"`
|
|
Configuration struct {
|
|
Fields []ConfigField `json:"fields"`
|
|
} `json:"configuration"`
|
|
Capabilities []string `json:"capabilities"`
|
|
Integration *struct {
|
|
ModuleID string `json:"module_id"`
|
|
PortID string `json:"port_id"`
|
|
} `json:"integration,omitempty"`
|
|
Module *struct {
|
|
Path string `json:"path"`
|
|
} `json:"module,omitempty"`
|
|
Backup struct {
|
|
Strategy string `json:"strategy"`
|
|
SourceMounts []string `json:"source_mounts"`
|
|
} `json:"backup"`
|
|
Healthcheck struct {
|
|
Type string `json:"type"`
|
|
PortID string `json:"port_id,omitempty"`
|
|
StartupTimeoutSeconds int `json:"startup_timeout_seconds"`
|
|
IntervalSeconds int `json:"interval_seconds"`
|
|
} `json:"healthcheck"`
|
|
Imports struct {
|
|
Supported bool `json:"supported"`
|
|
AcceptedFormats []string `json:"accepted_formats"`
|
|
MaxExtractedSizeGB int `json:"max_extracted_size_gb"`
|
|
RequiredPaths []string `json:"required_paths"`
|
|
DestinationMount string `json:"destination_mount"`
|
|
DestinationRelativePath string `json:"destination_relative_path"`
|
|
RequiresStoppedServer bool `json:"requires_stopped_server"`
|
|
} `json:"imports"`
|
|
Mods struct {
|
|
Supported bool `json:"supported"`
|
|
Provider string `json:"provider,omitempty"`
|
|
DestinationMount string `json:"destination_mount,omitempty"`
|
|
RestartRequired bool `json:"restart_required"`
|
|
} `json:"mods"`
|
|
Updates struct {
|
|
BackupBeforeUpdate bool `json:"backup_before_update"`
|
|
AutomaticDefault bool `json:"automatic_default"`
|
|
RollbackOnFailure bool `json:"rollback_on_failure"`
|
|
HealthTimeoutSeconds int `json:"health_timeout_seconds"`
|
|
CandidateTag string `json:"candidate_tag,omitempty"`
|
|
CandidateDigest string `json:"candidate_digest,omitempty"`
|
|
} `json:"updates"`
|
|
}
|
|
|
|
type Resources struct {
|
|
CPUCores float64 `json:"cpu_cores"`
|
|
MemoryMB int `json:"memory_mb"`
|
|
StorageGB int `json:"storage_gb"`
|
|
Other []string `json:"other,omitempty"`
|
|
}
|
|
|
|
type Port struct {
|
|
ID string `json:"id"`
|
|
ContainerPort int `json:"container_port"`
|
|
Protocol string `json:"protocol"`
|
|
Purpose string `json:"purpose"`
|
|
Publish bool `json:"publish"`
|
|
}
|
|
|
|
type Mount struct {
|
|
ID string `json:"id"`
|
|
ContainerPath string `json:"container_path"`
|
|
Category string `json:"category"`
|
|
Backup bool `json:"backup"`
|
|
ReadOnly bool `json:"read_only"`
|
|
}
|
|
|
|
type ConfigField struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Description string `json:"description,omitempty"`
|
|
Type string `json:"type"`
|
|
Visibility string `json:"visibility"`
|
|
Required bool `json:"required"`
|
|
Default any `json:"default,omitempty"`
|
|
Minimum *float64 `json:"minimum,omitempty"`
|
|
Maximum *float64 `json:"maximum,omitempty"`
|
|
Pattern string `json:"pattern,omitempty"`
|
|
Values []any `json:"values,omitempty"`
|
|
Target ConfigTarget `json:"target"`
|
|
}
|
|
|
|
// ConfigTarget is deliberately data-only. INI targets are relative to an
|
|
// explicitly declared writable mount, never to a host path.
|
|
type ConfigTarget struct {
|
|
Kind string `json:"kind"`
|
|
Name string `json:"name"`
|
|
Mount string `json:"mount,omitempty"`
|
|
File string `json:"file,omitempty"`
|
|
Section string `json:"section,omitempty"`
|
|
Key string `json:"key,omitempty"`
|
|
}
|
|
|
|
// Snapshot is an immutable validated template version.
|
|
type Snapshot struct {
|
|
Template Template
|
|
CanonicalYAML string
|
|
Digest string
|
|
Origin string
|
|
AssetRoot string
|
|
ModuleFiles map[string][]byte `json:"-"`
|
|
}
|
|
|
|
// LoadFS validates every template.yaml below root and returns stable snapshots.
|
|
func LoadFS(source fs.FS, root string) ([]Snapshot, error) {
|
|
pattern := path.Join(root, "*", "template.yaml")
|
|
names, err := fs.Glob(source, pattern)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list catalog templates: %w", err)
|
|
}
|
|
sort.Strings(names)
|
|
if len(names) == 0 {
|
|
return nil, errors.New("catalog contains no templates")
|
|
}
|
|
seen := make(map[string]struct{}, len(names))
|
|
result := make([]Snapshot, 0, len(names))
|
|
for _, name := range names {
|
|
body, err := fs.ReadFile(source, name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read template %s: %w", name, err)
|
|
}
|
|
snapshot, err := Validate(body, path.Dir(name), source)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("validate %s: %w", name, err)
|
|
}
|
|
key := snapshot.Template.ID + "@" + snapshot.Template.Version
|
|
if _, exists := seen[key]; exists {
|
|
return nil, fmt.Errorf("duplicate template version %s", key)
|
|
}
|
|
seen[key] = struct{}{}
|
|
result = append(result, snapshot)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ScanResult reports a best-effort scan of a local template directory.
|
|
type ScanResult struct {
|
|
Found int
|
|
Valid []Snapshot
|
|
Errors []ScanError
|
|
}
|
|
type ScanError struct {
|
|
Template string `json:"template"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// ScanDir scans immediate template directories. Invalid templates are reported
|
|
// separately, so they cannot make valid templates disappear.
|
|
func ScanDir(root string) (ScanResult, error) {
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return ScanResult{}, fmt.Errorf("read template directory: %w", err)
|
|
}
|
|
result := ScanResult{}
|
|
seen := map[string]struct{}{}
|
|
source := os.DirFS(root)
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
body, readErr := os.ReadFile(filepath.Join(root, name, "template.yaml"))
|
|
if errors.Is(readErr, os.ErrNotExist) {
|
|
continue
|
|
}
|
|
result.Found++
|
|
if readErr != nil {
|
|
result.Errors = append(result.Errors, ScanError{name, "template cannot be read"})
|
|
continue
|
|
}
|
|
if symlinkErr := rejectSymlinks(filepath.Join(root, name)); symlinkErr != nil {
|
|
result.Errors = append(result.Errors, ScanError{name, "template contains unsupported symbolic links"})
|
|
continue
|
|
}
|
|
snapshot, validateErr := Validate(body, name, source)
|
|
if validateErr != nil {
|
|
result.Errors = append(result.Errors, ScanError{name, publicValidationMessage(validateErr)})
|
|
continue
|
|
}
|
|
key := snapshot.Template.ID + "@" + snapshot.Template.Version
|
|
if _, duplicate := seen[key]; duplicate {
|
|
result.Errors = append(result.Errors, ScanError{name, "duplicate template ID and version"})
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
result.Valid = append(result.Valid, snapshot)
|
|
}
|
|
sort.Slice(result.Valid, func(i, j int) bool { return result.Valid[i].Template.ID < result.Valid[j].Template.ID })
|
|
return result, nil
|
|
}
|
|
|
|
func rejectSymlinks(root string) error {
|
|
return filepath.WalkDir(root, func(_ string, entry fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if entry.Type()&fs.ModeSymlink != 0 {
|
|
return errors.New("symbolic link")
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func publicValidationMessage(err error) string {
|
|
var validation *ValidationErrors
|
|
if errors.As(err, &validation) && len(validation.Issues) != 0 {
|
|
return validation.Issues[0].Path + ": " + validation.Issues[0].Message
|
|
}
|
|
return "template is invalid"
|
|
}
|
|
|
|
// Validate applies YAML safety, JSON Schema and cross-field validation.
|
|
func Validate(body []byte, assetRoot string, source fs.FS) (Snapshot, error) {
|
|
var document yaml.Node
|
|
decoder := yaml.NewDecoder(bytes.NewReader(body))
|
|
if err := decoder.Decode(&document); err != nil {
|
|
return Snapshot{}, &ValidationErrors{Issues: []ValidationIssue{{Path: "/", Message: "invalid YAML"}}}
|
|
}
|
|
if hasAlias(&document) {
|
|
return Snapshot{}, &ValidationErrors{Issues: []ValidationIssue{{Path: "/", Line: document.Line, Message: "YAML aliases are not allowed"}}}
|
|
}
|
|
var raw any
|
|
if err := document.Decode(&raw); err != nil {
|
|
return Snapshot{}, &ValidationErrors{Issues: []ValidationIssue{{Path: "/", Message: "invalid YAML value"}}}
|
|
}
|
|
canonical, err := json.Marshal(raw)
|
|
if err != nil {
|
|
return Snapshot{}, fmt.Errorf("canonicalize template: %w", err)
|
|
}
|
|
schema, err := compileSchema()
|
|
if err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
if err := schema.Validate(raw); err != nil {
|
|
return Snapshot{}, validationErrors(err, &document)
|
|
}
|
|
var template Template
|
|
if err := json.Unmarshal(canonical, &template); err != nil {
|
|
return Snapshot{}, fmt.Errorf("decode validated template: %w", err)
|
|
}
|
|
issues := crossValidate(template, assetRoot, source)
|
|
if len(issues) != 0 {
|
|
return Snapshot{}, &ValidationErrors{Issues: issues}
|
|
}
|
|
moduleFiles, moduleIssues := collectModuleFiles(template, assetRoot, source)
|
|
if len(moduleIssues) != 0 {
|
|
return Snapshot{}, &ValidationErrors{Issues: moduleIssues}
|
|
}
|
|
pretty, _ := json.MarshalIndent(raw, "", " ")
|
|
digester := sha256.New()
|
|
_, _ = digester.Write(canonical)
|
|
for _, name := range sortedModuleFiles(moduleFiles) {
|
|
_, _ = digester.Write([]byte{0})
|
|
_, _ = digester.Write([]byte(name))
|
|
_, _ = digester.Write([]byte{0})
|
|
_, _ = digester.Write(moduleFiles[name])
|
|
}
|
|
digest := digester.Sum(nil)
|
|
return Snapshot{
|
|
Template: template,
|
|
CanonicalYAML: string(pretty) + "\n",
|
|
Digest: hex.EncodeToString(digest),
|
|
Origin: template.Source.Type,
|
|
AssetRoot: assetRoot,
|
|
ModuleFiles: moduleFiles,
|
|
}, nil
|
|
}
|
|
|
|
func compileSchema() (*jsonschema.Schema, error) {
|
|
body, err := specs.Files.ReadFile("template.schema.json")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read template schema: %w", err)
|
|
}
|
|
compiler := jsonschema.NewCompiler()
|
|
compiler.AssertFormat()
|
|
compiler.UseRegexpEngine(compileECMAScript)
|
|
var document any
|
|
if err := json.Unmarshal(body, &document); err != nil {
|
|
return nil, fmt.Errorf("decode template schema: %w", err)
|
|
}
|
|
if err := compiler.AddResource(templateSchemaURL, document); err != nil {
|
|
return nil, fmt.Errorf("load template schema: %w", err)
|
|
}
|
|
schema, err := compiler.Compile(templateSchemaURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("compile template schema: %w", err)
|
|
}
|
|
return schema, nil
|
|
}
|
|
|
|
func compileModuleManifestSchema() (*jsonschema.Schema, error) {
|
|
body, err := specs.Files.ReadFile("module-manifest.schema.json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
compiler := jsonschema.NewCompiler()
|
|
compiler.AssertFormat()
|
|
compiler.UseRegexpEngine(compileECMAScript)
|
|
var document any
|
|
if err := json.Unmarshal(body, &document); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := compiler.AddResource(moduleManifestSchemaURL, document); err != nil {
|
|
return nil, err
|
|
}
|
|
return compiler.Compile(moduleManifestSchemaURL)
|
|
}
|
|
|
|
type ecmaRegexp regexp2.Regexp
|
|
|
|
func (expression *ecmaRegexp) MatchString(value string) bool {
|
|
matched, err := (*regexp2.Regexp)(expression).MatchString(value)
|
|
return err == nil && matched
|
|
}
|
|
|
|
func (expression *ecmaRegexp) String() string {
|
|
return (*regexp2.Regexp)(expression).String()
|
|
}
|
|
|
|
func compileECMAScript(pattern string) (jsonschema.Regexp, error) {
|
|
expression, err := regexp2.Compile(pattern, regexp2.ECMAScript)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return (*ecmaRegexp)(expression), nil
|
|
}
|
|
|
|
func validationErrors(err error, document *yaml.Node) error {
|
|
var validation *jsonschema.ValidationError
|
|
if !errors.As(err, &validation) {
|
|
return err
|
|
}
|
|
leaves := make([]*jsonschema.ValidationError, 0)
|
|
var walk func(*jsonschema.ValidationError)
|
|
walk = func(current *jsonschema.ValidationError) {
|
|
if len(current.Causes) == 0 {
|
|
leaves = append(leaves, current)
|
|
return
|
|
}
|
|
for _, cause := range current.Causes {
|
|
walk(cause)
|
|
}
|
|
}
|
|
walk(validation)
|
|
issues := make([]ValidationIssue, 0, len(leaves))
|
|
for _, leaf := range leaves {
|
|
pointer := "/" + strings.Join(leaf.InstanceLocation, "/")
|
|
issues = append(issues, ValidationIssue{Path: pointer, Line: lineFor(document, leaf.InstanceLocation), Message: "value does not satisfy the template schema"})
|
|
}
|
|
return &ValidationErrors{Issues: issues}
|
|
}
|
|
|
|
func crossValidate(template Template, assetRoot string, source fs.FS) []ValidationIssue {
|
|
var issues []ValidationIssue
|
|
for field, asset := range map[string]string{
|
|
"logo": template.Game.Artwork.Logo,
|
|
"image": template.Game.Artwork.Image,
|
|
"poster": template.Game.Artwork.Poster,
|
|
} {
|
|
if asset == "" {
|
|
continue
|
|
}
|
|
|
|
if _, err := fs.Stat(source, path.Join(assetRoot, asset)); err != nil {
|
|
issues = append(issues, ValidationIssue{
|
|
Path: "/game/artwork/" + field,
|
|
Message: "artwork asset is missing",
|
|
})
|
|
}
|
|
}
|
|
ports := make(map[string]Port)
|
|
for _, port := range template.Container.Ports {
|
|
if _, exists := ports[port.ID]; exists {
|
|
issues = append(issues, ValidationIssue{Path: "/container/ports", Message: "port IDs must be unique"})
|
|
}
|
|
ports[port.ID] = port
|
|
}
|
|
mounts := make(map[string]Mount)
|
|
for _, mount := range template.Storage.Mounts {
|
|
if _, exists := mounts[mount.ID]; exists {
|
|
issues = append(issues, ValidationIssue{Path: "/storage/mounts", Message: "mount IDs must be unique"})
|
|
}
|
|
mounts[mount.ID] = mount
|
|
}
|
|
fields := make(map[string]struct{})
|
|
for _, field := range template.Configuration.Fields {
|
|
if _, exists := fields[field.ID]; exists {
|
|
issues = append(issues, ValidationIssue{Path: "/configuration/fields", Message: "field IDs must be unique"})
|
|
}
|
|
fields[field.ID] = struct{}{}
|
|
if field.Type == "secret" && (field.Visibility != "secret" || field.Default != nil) {
|
|
issues = append(issues, ValidationIssue{Path: "/configuration/fields/" + field.ID, Message: "secret fields require secret visibility and no default"})
|
|
}
|
|
if field.Target.Kind == "ini" {
|
|
mount, ok := mounts[field.Target.Mount]
|
|
if !ok || mount.ReadOnly || field.Target.File == "" || field.Target.Key == "" || strings.HasPrefix(field.Target.File, "/") || strings.Contains(field.Target.File, "..") {
|
|
issues = append(issues, ValidationIssue{Path: "/configuration/fields/" + field.ID + "/target", Message: "INI target must use a writable declared mount and relative file"})
|
|
}
|
|
}
|
|
}
|
|
for _, mountID := range template.Backup.SourceMounts {
|
|
mount, exists := mounts[mountID]
|
|
if !exists || !mount.Backup {
|
|
issues = append(issues, ValidationIssue{Path: "/backup/source_mounts", Message: "backup sources must reference backup-enabled mounts"})
|
|
}
|
|
}
|
|
if template.Integration != nil {
|
|
port, exists := ports[template.Integration.PortID]
|
|
if !exists || port.Purpose != "integration" {
|
|
issues = append(issues, ValidationIssue{Path: "/integration/port_id", Message: "integration must reference an integration port"})
|
|
}
|
|
}
|
|
if template.Integration != nil && template.Module == nil {
|
|
issues = append(issues, ValidationIssue{Path: "/module", Message: "integration requires a template-local module"})
|
|
}
|
|
if template.Healthcheck.PortID != "" {
|
|
if _, exists := ports[template.Healthcheck.PortID]; !exists {
|
|
issues = append(issues, ValidationIssue{Path: "/healthcheck/port_id", Message: "healthcheck port does not exist"})
|
|
}
|
|
}
|
|
if template.Imports.Supported {
|
|
if _, exists := mounts[template.Imports.DestinationMount]; !exists {
|
|
issues = append(issues, ValidationIssue{Path: "/imports/destination_mount", Message: "import destination mount does not exist"})
|
|
}
|
|
}
|
|
if template.Requirements.Recommended.CPUCores < template.Requirements.Minimum.CPUCores || template.Requirements.Recommended.MemoryMB < template.Requirements.Minimum.MemoryMB || template.Requirements.Recommended.StorageGB < template.Requirements.Minimum.StorageGB {
|
|
issues = append(issues, ValidationIssue{Path: "/requirements/recommended", Message: "recommended resources must not be below minimum resources"})
|
|
}
|
|
for _, asset := range template.Container.Assets {
|
|
if _, err := fs.Stat(source, path.Join(assetRoot, asset.Source)); err != nil {
|
|
issues = append(issues, ValidationIssue{Path: "/container/assets/" + asset.Source, Message: "asset is missing"})
|
|
}
|
|
}
|
|
return issues
|
|
}
|
|
|
|
func collectModuleFiles(template Template, assetRoot string, source fs.FS) (map[string][]byte, []ValidationIssue) {
|
|
if template.Module == nil {
|
|
return nil, nil
|
|
}
|
|
if !validModulePath(template.Module.Path) {
|
|
return nil, []ValidationIssue{{Path: "/module/path", Message: "module path must remain inside the template module directory"}}
|
|
}
|
|
root := path.Join(assetRoot, "module")
|
|
files := map[string][]byte{}
|
|
var totalBytes int
|
|
err := fs.WalkDir(source, root, func(name string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if entry.IsDir() {
|
|
return nil
|
|
}
|
|
if entry.Type()&fs.ModeSymlink != 0 {
|
|
return errors.New("symbolic link")
|
|
}
|
|
body, err := fs.ReadFile(source, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
totalBytes += len(body)
|
|
if totalBytes > maxModuleBundleBytes {
|
|
return errors.New("module bundle exceeds size limit")
|
|
}
|
|
rel := strings.TrimPrefix(name, strings.TrimSuffix(assetRoot, "/")+"/")
|
|
if !strings.HasPrefix(rel, "module/") {
|
|
return errors.New("invalid module path")
|
|
}
|
|
files[rel] = body
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, []ValidationIssue{{Path: "/module", Message: "module directory cannot be read"}}
|
|
}
|
|
if _, ok := files[template.Module.Path]; !ok {
|
|
return nil, []ValidationIssue{{Path: "/module/path", Message: "declared module manifest is missing"}}
|
|
}
|
|
if err := validateModuleManifest(files[template.Module.Path], files); err != nil {
|
|
return nil, []ValidationIssue{{Path: "/module", Message: "declared module bundle is invalid"}}
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func validateModuleManifest(body []byte, files map[string][]byte) error {
|
|
var raw any
|
|
if err := yaml.Unmarshal(body, &raw); err != nil {
|
|
return err
|
|
}
|
|
schema, err := compileModuleManifestSchema()
|
|
if err != nil || schema.Validate(raw) != nil {
|
|
return errors.New("invalid manifest")
|
|
}
|
|
var manifest struct {
|
|
Artifacts struct {
|
|
WASM string `yaml:"wasm"`
|
|
} `yaml:"artifacts"`
|
|
}
|
|
if err := yaml.Unmarshal(body, &manifest); err != nil || manifest.Artifacts.WASM == "" {
|
|
return errors.New("invalid artifact")
|
|
}
|
|
if _, ok := files["module/"+manifest.Artifacts.WASM]; !ok {
|
|
return errors.New("missing wasm artifact")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validModulePath(value string) bool {
|
|
return value != "" && !path.IsAbs(value) && path.Clean(value) == value && strings.HasPrefix(value, "module/") && !strings.Contains(value, "..")
|
|
}
|
|
|
|
func sortedModuleFiles(files map[string][]byte) []string {
|
|
names := make([]string, 0, len(files))
|
|
for name := range files {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func hasAlias(node *yaml.Node) bool {
|
|
if node.Kind == yaml.AliasNode {
|
|
return true
|
|
}
|
|
for _, child := range node.Content {
|
|
if hasAlias(child) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func lineFor(document *yaml.Node, segments []string) int {
|
|
node := document
|
|
if node.Kind == yaml.DocumentNode && len(node.Content) != 0 {
|
|
node = node.Content[0]
|
|
}
|
|
for _, segment := range segments {
|
|
if node.Kind == yaml.MappingNode {
|
|
found := false
|
|
for index := 0; index+1 < len(node.Content); index += 2 {
|
|
if node.Content[index].Value == segment {
|
|
node = node.Content[index+1]
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return node.Line
|
|
}
|
|
continue
|
|
}
|
|
if node.Kind == yaml.SequenceNode {
|
|
var index int
|
|
if _, err := fmt.Sscanf(segment, "%d", &index); err != nil || index < 0 || index >= len(node.Content) {
|
|
return node.Line
|
|
}
|
|
node = node.Content[index]
|
|
}
|
|
}
|
|
return node.Line
|
|
}
|