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]*$`) buildTagRe = regexp.MustCompile(`^[a-z0-9_]+$`) ) // Manifest is the ordered app plugin list used to generate imports, plus // the Go build tags summer build passes to go build (summer.yaml build.tags). type Manifest struct { Module string Binary string Plugins []Plugin Tags []string } // 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) } tags, err := loadBuildTags(tree["build"]) if err != nil { return Manifest{}, err } m.Tags = tags return m, nil } // loadBuildTags reads the optional build mapping and its tags list. Entries // are trimmed and validated; duplicates are dropped keeping the first. func loadBuildTags(raw any) ([]string, error) { if raw == nil { return nil, nil } section := asMap(raw) if section == nil { return nil, fmt.Errorf("build: build must be a mapping (build.tags)") } rawTags, ok := section["tags"] if !ok || rawTags == nil { return nil, nil } list, ok := rawTags.([]any) if !ok { return nil, fmt.Errorf("build: build.tags must be a list of tags") } var tags []string seen := make(map[string]struct{}, len(list)) for i, item := range list { s, ok := item.(string) if !ok { return nil, fmt.Errorf("build: build.tags[%d] must be a string", i) } tag := strings.TrimSpace(s) if err := ValidateBuildTag(tag); err != nil { return nil, fmt.Errorf("build: build.tags[%d]: %w", i, err) } if _, dup := seen[tag]; dup { continue } seen[tag] = struct{}{} tags = append(tags, tag) } return tags, nil } // ValidateBuildTag requires a Go build tag of lowercase letters, digits and // underscores, so a summer.yaml value can never smuggle a flag into go build. func ValidateBuildTag(tag string) error { if tag == "" { return fmt.Errorf("build tag is empty") } if !buildTagRe.MatchString(tag) { return fmt.Errorf("build tag %q must match ^[a-z0-9_]+$", tag) } return nil } // SaveManifest writes summer.yaml with plugins in slice order and, when // present, the build.tags list. 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 len(m.Tags) > 0 { b.WriteString("build:\n") b.WriteString(" tags:\n") for i, tag := range m.Tags { if err := ValidateBuildTag(tag); err != nil { return fmt.Errorf("build: build.tags[%d]: %w", i, err) } fmt.Fprintf(&b, " - %s\n", tag) } } 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 } // FindAppDir walks up from start looking for summer.yaml. func FindAppDir(start string) (string, error) { return findAppDir(start) } 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 } }