package build import ( "errors" "fmt" "os" "path/filepath" "regexp" "strings" "unicode" "git.golem15.com/golem15/summercms/modules/bonfire" "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 } // appendManifestPlugins adds plugins to the end of the plugins list in the // summer.yaml at path by inserting text, so comments, blank lines and the // order of the other keys stay as they are. The entries take the // indentation of the existing items; a file without a plugins key gets a // new block at its end, and an inline (flow) plugins list is rewritten as // a block list with the new entries. The result is parsed again with // LoadManifest, and any error restores the original bytes. When an inline // list cannot be edited in place, the whole file is written again with // SaveManifest, as plugin:add did before (WR-11): rewrote is then true and // the file's comments are gone. func appendManifestPlugins(path string, plugins []Plugin) (rewrote bool, err error) { if len(plugins) == 0 { return false, nil } for i, p := range plugins { if err := ValidatePluginID(p.ID); err != nil { return false, fmt.Errorf("build: plugins[%d]: %w", i, err) } if err := validateModulePath(p.Module); err != nil { return false, fmt.Errorf("build: plugins[%d] module: %w", i, err) } } orig, err := os.ReadFile(path) if err != nil { return false, fmt.Errorf("build: read %s: %w", path, err) } before, err := LoadManifest(path) if err != nil { return false, err } want := append(append([]Plugin(nil), before.Plugins...), plugins...) // rewrite is the fallback for an inline list the text edit cannot // handle: the whole manifest again, as SaveManifest writes it. rewrite := func() (bool, error) { m := before m.Plugins = want if err := SaveManifest(path, m); err != nil { return false, err } return true, nil } updated, err := insertManifestPlugins(string(orig), before.Plugins, plugins) if errors.Is(err, errInlinePlugins) { return rewrite() } if err != nil { return false, fmt.Errorf("build: edit %s: %w", path, err) } if err := os.WriteFile(path, []byte(updated), 0o644); err != nil { return false, fmt.Errorf("build: write %s: %w", path, err) } restore := func(cause error) error { if werr := os.WriteFile(path, orig, 0o644); werr != nil { return fmt.Errorf("%w (restoring %s also failed: %v)", cause, path, werr) } return cause } after, err := LoadManifest(path) if err != nil { return false, restore(fmt.Errorf("build: summer.yaml edit did not parse, left unchanged: %w", err)) } if len(after.Plugins) != len(want) { return false, restore(fmt.Errorf("build: summer.yaml edit gave %d plugins instead of %d, left unchanged", len(after.Plugins), len(want))) } for i := range want { if after.Plugins[i] != want[i] { return false, restore(fmt.Errorf("build: summer.yaml edit changed plugins[%d], left unchanged", i)) } } return false, nil } // errInlinePlugins reports an inline plugins list the text edit cannot // rewrite in place. var errInlinePlugins = errors.New("the plugins list is written inline in a form summer cannot edit in place") // insertManifestPlugins returns src with plugins appended to its // top-level plugins block. An inline (flow) list holding existing is // replaced by a block list of existing and plugins; one whose end it // cannot find gives errInlinePlugins. func insertManifestPlugins(src string, existing, plugins []Plugin) (string, error) { lines := strings.SplitAfter(src, "\n") if n := len(lines); n > 0 && lines[n-1] == "" { lines = lines[:n-1] } key := -1 for i, l := range lines { if strings.HasPrefix(l, "plugins:") { key = i break } } // dash is the indentation before "-", gap the spacing after it. render := func(dash, gap, keyIndent string) string { var b strings.Builder for _, p := range plugins { fmt.Fprintf(&b, "%s-%sid: %s\n%smodule: %s\n", dash, gap, p.ID, keyIndent, p.Module) } return b.String() } if key < 0 { out := strings.Join(lines, "") if out != "" && !strings.HasSuffix(out, "\n") { out += "\n" } return out + "plugins:\n" + render(" ", " ", " "), nil } rest := strings.TrimSpace(stripYAMLComment(strings.TrimPrefix(lines[key], "plugins:"))) switch rest { case "", "[]", "~", "null": default: // An inline list such as plugins: [{id: a, module: b}]: replace // its lines with a block list of the same items and the new ones. end := flowEnd(lines, key, len("plugins:")) if end < 0 { return "", errInlinePlugins } all := append(append([]Plugin(nil), existing...), plugins...) var b strings.Builder b.WriteString(strings.Join(lines[:key], "")) b.WriteString("plugins:\n") for _, p := range all { fmt.Fprintf(&b, " - id: %s\n module: %s\n", p.ID, p.Module) } b.WriteString(strings.Join(lines[end+1:], "")) return b.String(), nil } // The block runs until the next line that starts a top-level key. end := key + 1 last := key dash, gap, keyIndent := "", " ", "" for ; end < len(lines); end++ { l := strings.TrimRight(lines[end], "\r\n") trimmed := strings.TrimSpace(l) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } indented := l[0] == ' ' || l[0] == '\t' if !indented && !strings.HasPrefix(l, "-") { break } last = end if dash == "" && strings.HasPrefix(trimmed, "-") { dash = l[:len(l)-len(strings.TrimLeft(l, " \t"))] afterDash := strings.TrimLeft(trimmed[1:], " \t") if g := trimmed[1 : len(trimmed)-len(afterDash)]; g != "" && !strings.Contains(g, "\t") { gap = g } keyIndent = strings.Repeat(" ", len(dash)+1+len(gap)) } } if dash == "" && keyIndent == "" { dash, keyIndent = " ", " " } head := lines[:last+1] if rest != "" { head = append(append([]string(nil), lines[:key]...), "plugins:\n") head = append(head, lines[key+1:last+1]...) } tail := lines[last+1:] out := strings.Join(head, "") if !strings.HasSuffix(out, "\n") { out += "\n" } return out + render(dash, gap, keyIndent) + strings.Join(tail, ""), nil } // flowEnd returns the index of the line where the flow collection that // starts at lines[key][col:] closes, or -1 when it does not close, does // not start with "[", or has more than a comment after it. Quoted strings // are skipped; a "#" after a blank outside quotes starts a comment. func flowEnd(lines []string, key, col int) int { depth, started := 0, false var quote byte for i := key; i < len(lines); i++ { line := strings.TrimRight(lines[i], "\r\n") j := 0 if i == key { j = col } for ; j < len(line); j++ { c := line[j] switch { case quote != 0: if c == quote { quote = 0 } case c == '"' || c == '\'': quote = c case c == '#' && (j == 0 || line[j-1] == ' ' || line[j-1] == '\t'): j = len(line) case c == ' ' || c == '\t': case !started && c != '[': return -1 case c == '[' || c == '{': depth++ started = true case c == ']' || c == '}': depth-- if depth == 0 { after := strings.TrimSpace(line[j+1:]) if after != "" && !strings.HasPrefix(after, "#") { return -1 } return i } } } if quote != 0 { return -1 } } return -1 } // stripYAMLComment drops a trailing " # comment" from a YAML value. func stripYAMLComment(s string) string { if i := strings.Index(s, " #"); i >= 0 { return s[:i] } if strings.HasPrefix(strings.TrimSpace(s), "#") { return "" } return s } // 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 } // cliInvocation is the summer command line being run (os.Args without the // program), shell-quoted, for the Try line of a hint. func cliInvocation() string { parts := make([]string, 0, len(os.Args)) for _, a := range os.Args[1:] { if a == "" || strings.ContainsAny(a, " \t\n'\"\\$`*?[]{}()<>|&;#~!") { a = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'" } parts = append(parts, a) } return strings.Join(parts, " ") } // cliCommand is the summer command being run (the first argument that is // not an option), or def when there is none, for hint texts. func cliCommand(def string) string { args := os.Args[1:] for i := 0; i < len(args); i++ { switch a := args[i]; { case a == "--env": i++ case a != "" && !strings.HasPrefix(a, "-"): return a } } return def } // 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 "", bonfire.WithHint(fmt.Errorf("build: no %s found from %s", manifestName, start), "summer "+cliCommand("build")+" runs inside an application: a directory with summer.yaml, or one below it.", "cd ; {bin} "+cliInvocation()) } 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 } }