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]*$`) // 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 ValidVersion(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 }