- kiosk.ReadIdentity: ID()/Requires() as literals or constants across packages and modules, read with go/parser, never run - plugin minimums from go.mod requires; v0.0.0 is an error, a pseudo-version warns - kiosk.Validate, kiosk.CheckT (no testing import) - plugin:add reads IDs through kiosk.ReadIdentity, so const-ID plugins register
164 lines
5.3 KiB
Go
164 lines
5.3 KiB
Go
package kiosk
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/goccy/go-yaml"
|
|
)
|
|
|
|
// Manifest is one plugin.yaml. Fields are written in declaration order.
|
|
type Manifest struct {
|
|
// Schema is the plugin.yaml format version (SchemaVersion when written).
|
|
Schema int `yaml:"schema"`
|
|
// ID is the plugin ID that plugin.go's ID() returns.
|
|
ID string `yaml:"id"`
|
|
// Module is the go.mod module path: what go get takes.
|
|
Module string `yaml:"module"`
|
|
// Package is the plugin's import path: what summer.yaml lists. It
|
|
// differs from Module for a plugin in a subdirectory of its module.
|
|
Package string `yaml:"package"`
|
|
// Name is the display name: the README's H1 or the name override.
|
|
Name string `yaml:"name"`
|
|
// Description is the README's one-sentence summary or the override.
|
|
Description string `yaml:"description"`
|
|
// Framework is the minimum framework version: the go.mod require of
|
|
// FrameworkModule.
|
|
Framework string `yaml:"framework"`
|
|
// Requires maps each plugin ID that Requires() returns to its minimum
|
|
// version; empty when the plugin ships in the same module or its
|
|
// module is not a go.mod require.
|
|
Requires Requires `yaml:"requires,omitempty"`
|
|
// Overrides holds the --name and --description values; nil when
|
|
// neither is set.
|
|
Overrides *Overrides `yaml:"overrides,omitempty"`
|
|
}
|
|
|
|
// Overrides are the name and description set with summer plugin:manifest
|
|
// --name and --description. They survive regeneration until cleared.
|
|
type Overrides struct {
|
|
Name string `yaml:"name,omitempty"`
|
|
Description string `yaml:"description,omitempty"`
|
|
}
|
|
|
|
// Requires maps a required plugin ID to its minimum version. It marshals
|
|
// with the IDs sorted, so the generated bytes are stable.
|
|
type Requires map[string]string
|
|
|
|
// MarshalYAML emits the map as a sorted mapping.
|
|
func (r Requires) MarshalYAML() (any, error) {
|
|
ids := make([]string, 0, len(r))
|
|
for id := range r {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
out := make(yaml.MapSlice, 0, len(ids))
|
|
for _, id := range ids {
|
|
out = append(out, yaml.MapItem{Key: id, Value: r[id]})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Marshal returns the plugin.yaml bytes: the Header line, then the fields.
|
|
func Marshal(m Manifest) ([]byte, error) {
|
|
if m.Overrides != nil && *m.Overrides == (Overrides{}) {
|
|
m.Overrides = nil
|
|
}
|
|
body, err := yaml.Marshal(m)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kiosk: marshal %s: %w", FileName, err)
|
|
}
|
|
var b bytes.Buffer
|
|
b.WriteString(Header)
|
|
b.WriteByte('\n')
|
|
b.Write(body)
|
|
return b.Bytes(), nil
|
|
}
|
|
|
|
// Parse reads plugin.yaml bytes. A schema newer than SchemaVersion is an
|
|
// error that says to update summer; unknown fields are ignored.
|
|
func Parse(data []byte) (Manifest, error) {
|
|
var m Manifest
|
|
if err := yaml.Unmarshal(data, &m); err != nil {
|
|
return Manifest{}, fmt.Errorf("kiosk: parse %s: %w", FileName, err)
|
|
}
|
|
if m.Schema > SchemaVersion {
|
|
return Manifest{}, fmt.Errorf("kiosk: %s has schema %d, this summer reads up to %d; update summer", FileName, m.Schema, SchemaVersion)
|
|
}
|
|
if err := Validate(m); err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
var (
|
|
pluginIDRe = regexp.MustCompile(`^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`)
|
|
releaseVersion = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
|
|
)
|
|
|
|
// Validate checks a manifest: the schema is between 1 and SchemaVersion,
|
|
// the ID and every required ID are lowercase vendor.plugin, module and
|
|
// package are plain module paths (package inside module), name and
|
|
// description are set, and the framework and every non-empty plugin
|
|
// minimum is a vX.Y.Z release or a Go pseudo-version.
|
|
func Validate(m Manifest) error {
|
|
if m.Schema < 1 || m.Schema > SchemaVersion {
|
|
return fmt.Errorf("kiosk: schema %d is not between 1 and %d", m.Schema, SchemaVersion)
|
|
}
|
|
if !pluginIDRe.MatchString(m.ID) {
|
|
return fmt.Errorf("kiosk: id %q must be lowercase vendor.plugin", m.ID)
|
|
}
|
|
if err := validPath(m.Module); err != nil {
|
|
return fmt.Errorf("kiosk: module: %w", err)
|
|
}
|
|
if err := validPath(m.Package); err != nil {
|
|
return fmt.Errorf("kiosk: package: %w", err)
|
|
}
|
|
if m.Package != m.Module && !strings.HasPrefix(m.Package, m.Module+"/") {
|
|
return fmt.Errorf("kiosk: package %q is not inside module %q", m.Package, m.Module)
|
|
}
|
|
if strings.TrimSpace(m.Name) == "" {
|
|
return fmt.Errorf("kiosk: name is empty")
|
|
}
|
|
if strings.TrimSpace(m.Description) == "" {
|
|
return fmt.Errorf("kiosk: description is empty")
|
|
}
|
|
if !releaseOrPseudo(m.Framework) {
|
|
return fmt.Errorf("kiosk: framework %q must be a vX.Y.Z release", m.Framework)
|
|
}
|
|
for id, v := range m.Requires {
|
|
if !pluginIDRe.MatchString(id) {
|
|
return fmt.Errorf("kiosk: required id %q must be lowercase vendor.plugin", id)
|
|
}
|
|
if v != "" && !releaseOrPseudo(v) {
|
|
return fmt.Errorf("kiosk: minimum %q of %s must be a vX.Y.Z release", v, id)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func releaseOrPseudo(v string) bool {
|
|
return releaseVersion.MatchString(v) || isPseudoVersion(v)
|
|
}
|
|
|
|
// validPath accepts a slash-separated module or import path.
|
|
func validPath(p string) error {
|
|
switch {
|
|
case p == "":
|
|
return fmt.Errorf("is empty")
|
|
case strings.HasPrefix(p, "-"):
|
|
return fmt.Errorf("%q must not start with -", p)
|
|
case strings.ContainsAny(p, " \t\r\n\\\"'`$;&|*?<>()[]{}@"):
|
|
return fmt.Errorf("%q contains invalid characters", p)
|
|
}
|
|
for _, seg := range strings.Split(p, "/") {
|
|
if seg == "" || seg == "." || seg == ".." {
|
|
return fmt.Errorf("%q has an invalid path segment", p)
|
|
}
|
|
}
|
|
return nil
|
|
}
|