package build import ( "fmt" "os" "path/filepath" "regexp" "strings" "unicode" "github.com/knadh/koanf/parsers/yaml" ) const manifestName = "summer.yaml" var pluginIDRe = regexp.MustCompile(`^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`) // Manifest is the ordered app plugin list used to generate imports. type Manifest struct { Module string Binary string Plugins []Plugin } // Plugin is one compiled plugin module in manifest order. type Plugin struct { ID string Module string } // LoadManifest reads and validates summer.yaml at path. func LoadManifest(path string) (Manifest, error) { raw, err := os.ReadFile(path) if err != nil { return Manifest{}, fmt.Errorf("build: read %s: %w", path, err) } tree, err := yaml.Parser().Unmarshal(raw) if err != nil { return Manifest{}, fmt.Errorf("build: parse %s: %w", path, err) } m := Manifest{ Module: strings.TrimSpace(asString(tree["module"])), Binary: strings.TrimSpace(asString(tree["binary"])), } if err := validateModulePath(m.Module); err != nil { return Manifest{}, fmt.Errorf("build: module: %w", err) } if err := validateBinary(m.Binary); err != nil { return Manifest{}, fmt.Errorf("build: binary: %w", err) } seenID := make(map[string]struct{}) seenMod := make(map[string]struct{}) for i, item := range asSlice(tree["plugins"]) { entry := asMap(item) if entry == nil { return Manifest{}, fmt.Errorf("build: plugins[%d] must be a mapping with id and module", i) } p := Plugin{ ID: strings.TrimSpace(asString(entry["id"])), Module: strings.TrimSpace(asString(entry["module"])), } if err := ValidatePluginID(p.ID); err != nil { return Manifest{}, fmt.Errorf("build: plugins[%d]: %w", i, err) } if _, dup := seenID[p.ID]; dup { return Manifest{}, fmt.Errorf("build: duplicate plugin id %q", p.ID) } if err := validateModulePath(p.Module); err != nil { return Manifest{}, fmt.Errorf("build: plugins[%d] module: %w", i, err) } if _, dup := seenMod[p.Module]; dup { return Manifest{}, fmt.Errorf("build: duplicate plugin module %q", p.Module) } seenID[p.ID] = struct{}{} seenMod[p.Module] = struct{}{} m.Plugins = append(m.Plugins, p) } return m, nil } // SaveManifest writes summer.yaml with plugins in slice order. func SaveManifest(path string, m Manifest) error { if err := validateModulePath(m.Module); err != nil { return fmt.Errorf("build: module: %w", err) } if err := validateBinary(m.Binary); err != nil { return fmt.Errorf("build: binary: %w", err) } var b strings.Builder fmt.Fprintf(&b, "module: %s\n", m.Module) fmt.Fprintf(&b, "binary: %s\n", m.Binary) b.WriteString("plugins:\n") for i, p := range m.Plugins { if err := ValidatePluginID(p.ID); err != nil { return fmt.Errorf("build: plugins[%d]: %w", i, err) } if err := validateModulePath(p.Module); err != nil { return fmt.Errorf("build: plugins[%d] module: %w", i, err) } fmt.Fprintf(&b, " - id: %s\n", p.ID) fmt.Fprintf(&b, " module: %s\n", p.Module) } if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { return fmt.Errorf("build: write %s: %w", path, err) } return nil } // ValidatePluginID requires a lowercase vendor.plugin identifier. func ValidatePluginID(id string) error { if id == "" { return fmt.Errorf("plugin id is empty") } if strings.ContainsAny(id, " \t\r\n\"'`/\\") { return fmt.Errorf("plugin id %q contains invalid characters", id) } if !pluginIDRe.MatchString(id) { return fmt.Errorf("plugin id %q must be lowercase vendor.plugin", id) } return nil } func findAppDir(start string) (string, error) { if start == "" { return "", fmt.Errorf("build: app directory is empty") } dir, err := filepath.Abs(start) if err != nil { return "", fmt.Errorf("build: resolve app directory: %w", err) } for { if _, err := os.Stat(filepath.Join(dir, manifestName)); err == nil { return dir, nil } parent := filepath.Dir(dir) if parent == dir { return "", fmt.Errorf("build: no %s found from %s", manifestName, start) } dir = parent } } func validateBinary(name string) error { if name == "" { return fmt.Errorf("is empty") } if name != filepath.Base(name) || strings.ContainsRune(name, os.PathSeparator) { return fmt.Errorf("%q must be a bare file name", name) } if name == "." || name == ".." { return fmt.Errorf("%q is not a valid binary name", name) } for _, r := range name { if r > unicode.MaxASCII || !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '_' || r == '-') { return fmt.Errorf("%q contains invalid characters", name) } } return nil } func validateModulePath(path string) error { if path == "" { return fmt.Errorf("is empty") } if strings.HasPrefix(path, "-") { return fmt.Errorf("%q must not start with -", path) } if strings.ContainsAny(path, " \t\r\n\\\"'`$;&|*?<>()[]{}") { return fmt.Errorf("%q contains invalid characters", path) } if filepath.IsAbs(path) || strings.Contains(path, `\`) { return fmt.Errorf("%q is not a valid module path", path) } for _, seg := range strings.Split(path, "/") { if seg == "" || seg == "." || seg == ".." { return fmt.Errorf("%q has an invalid path segment", path) } if strings.Contains(seg, "@") { return fmt.Errorf("%q must not contain a version suffix", path) } } return nil } func asString(v any) string { s, _ := v.(string) return s } func asSlice(v any) []any { switch s := v.(type) { case []any: return s case nil: return nil default: return nil } } func asMap(v any) map[string]any { switch m := v.(type) { case map[string]any: return m case map[any]any: out := make(map[string]any, len(m)) for k, val := range m { ks, ok := k.(string) if !ok { continue } out[ks] = val } return out default: return nil } }