feat(01-01): generate and build hello app from manifest
- Add summer build with validated summer.yaml codegen - Generate stable main.go and plugins.gen.go then go build - Smoke-test the built hello binary greeter:hello command
This commit is contained in:
33
cmd/summer/main.go
Normal file
33
cmd/summer/main.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/internal/build"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
root := bonfire.NewRoot("summer", []bonfire.Command{buildCommand()}, os.Stdout)
|
||||||
|
root.SetArgs(os.Args[1:])
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCommand() bonfire.Command {
|
||||||
|
return bonfire.Command{
|
||||||
|
Name: "build",
|
||||||
|
Description: "Generate plugin imports and build the app binary",
|
||||||
|
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return build.App(ctx, dir, out)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
41
cmd/summer/main_test.go
Normal file
41
cmd/summer/main_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToolDoesNotImportExamplePlugins(t *testing.T) {
|
||||||
|
roots := []string{".", filepath.Join("..", "..", "internal", "build")}
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
for _, root := range roots {
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(name, ".go") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
file, err := parser.ParseFile(fset, filepath.Join(root, name), nil, parser.ImportsOnly)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, spec := range file.Imports {
|
||||||
|
path, err := strconv.Unquote(spec.Path.Value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(path, "examples/hello") {
|
||||||
|
t.Fatalf("%s imports %s", name, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -15,3 +18,46 @@ func TestGreeterHelloPrintsAppName(t *testing.T) {
|
|||||||
t.Fatalf("output = %q, want %q", got, "hello-app")
|
t.Fatalf("output = %q, want %q", got, "hello-app")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuiltBinaryGreeterHello(t *testing.T) {
|
||||||
|
first := runSummerBuild(t)
|
||||||
|
h1 := generatedHash(t)
|
||||||
|
second := runSummerBuild(t)
|
||||||
|
h2 := generatedHash(t)
|
||||||
|
if h1 != h2 {
|
||||||
|
t.Fatalf("generated files changed on second build\nfirst:\n%s\nsecond:\n%s", first, second)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("./bin/hello", "greeter:hello")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bin/hello greeter:hello: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(string(out)); got != "hello-app" {
|
||||||
|
t.Fatalf("output = %q, want %q", got, "hello-app")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSummerBuild(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("go", "run", "../../cmd/summer", "build")
|
||||||
|
cmd.Dir = "."
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("summer build: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedHash(t *testing.T) [32]byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for _, name := range []string{"main.go", "plugins.gen.go"} {
|
||||||
|
body, err := os.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
buf.Write(body)
|
||||||
|
}
|
||||||
|
return sha256.Sum256(buf.Bytes())
|
||||||
|
}
|
||||||
|
|||||||
308
internal/build/build.go
Normal file
308
internal/build/build.go
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
package build
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"go/format"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/knadh/koanf/parsers/yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
const manifestName = "summer.yaml"
|
||||||
|
|
||||||
|
// 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 p.ID == "" {
|
||||||
|
return Manifest{}, fmt.Errorf("build: plugins[%d] is missing id", i)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(p.ID, " \t\r\n\"'`") {
|
||||||
|
return Manifest{}, fmt.Errorf("build: plugins[%d] has invalid id %q", i, p.ID)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// App generates app sources from summer.yaml and runs go build in appDir.
|
||||||
|
func App(ctx context.Context, appDir string, out io.Writer) error {
|
||||||
|
if appDir == "" {
|
||||||
|
return fmt.Errorf("build: app directory is empty")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(appDir) {
|
||||||
|
abs, err := filepath.Abs(appDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build: resolve app directory: %w", err)
|
||||||
|
}
|
||||||
|
appDir = abs
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = os.Stdout
|
||||||
|
}
|
||||||
|
m, err := LoadManifest(filepath.Join(appDir, manifestName))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := generate(appDir, m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
binRel := filepath.Join("bin", m.Binary)
|
||||||
|
if err := os.MkdirAll(filepath.Join(appDir, "bin"), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("build: create bin dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "go", "build", "-o", binRel, ".")
|
||||||
|
cmd.Dir = appDir
|
||||||
|
cmd.Stdout = out
|
||||||
|
cmd.Stderr = out
|
||||||
|
start := time.Now()
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("build: go build: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "built %s in %s\n", m.Binary, time.Since(start).Round(time.Millisecond))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generate(appDir string, m Manifest) error {
|
||||||
|
mainSrc, err := generateMain(m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pluginsSrc, err := generatePluginsGen(m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeIfChanged(filepath.Join(appDir, "main.go"), mainSrc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeIfChanged(filepath.Join(appDir, "plugins.gen.go"), pluginsSrc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateMain(m Manifest) ([]byte, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n")
|
||||||
|
b.WriteString("package main\n\n")
|
||||||
|
b.WriteString("import (\n")
|
||||||
|
b.WriteString("\t\"fmt\"\n")
|
||||||
|
b.WriteString("\t\"io\"\n")
|
||||||
|
b.WriteString("\t\"os\"\n\n")
|
||||||
|
b.WriteString("\t\"git.golem15.com/golem15/summercms/backpack\"\n")
|
||||||
|
b.WriteString("\t\"git.golem15.com/golem15/summercms/bonfire\"\n")
|
||||||
|
b.WriteString("\t\"git.golem15.com/golem15/summercms/compass\"\n")
|
||||||
|
b.WriteString("\t\"git.golem15.com/golem15/summercms/pact\"\n")
|
||||||
|
b.WriteString("\t\"git.golem15.com/golem15/summercms/party\"\n")
|
||||||
|
b.WriteString(")\n\n")
|
||||||
|
b.WriteString("func main() {\n")
|
||||||
|
b.WriteString("\tif err := run(os.Args[1:], os.Stdout); err != nil {\n")
|
||||||
|
b.WriteString("\t\tfmt.Fprintln(os.Stderr, err)\n")
|
||||||
|
b.WriteString("\t\tos.Exit(1)\n")
|
||||||
|
b.WriteString("\t}\n")
|
||||||
|
b.WriteString("}\n\n")
|
||||||
|
b.WriteString("func run(args []string, out io.Writer) error {\n")
|
||||||
|
b.WriteString("\tcfg, err := compass.Load(\"config\")\n")
|
||||||
|
b.WriteString("\tif err != nil {\n")
|
||||||
|
b.WriteString("\t\treturn err\n")
|
||||||
|
b.WriteString("\t}\n")
|
||||||
|
b.WriteString("\tapp := backpack.New(cfg)\n")
|
||||||
|
b.WriteString("\tplugins, err := party.Activate(app, PluginIDs)\n")
|
||||||
|
b.WriteString("\tif err != nil {\n")
|
||||||
|
b.WriteString("\t\treturn err\n")
|
||||||
|
b.WriteString("\t}\n")
|
||||||
|
b.WriteString("\tvar commands []bonfire.Command\n")
|
||||||
|
b.WriteString("\tfor _, plugin := range plugins {\n")
|
||||||
|
b.WriteString("\t\tif hasCommands, ok := plugin.(pact.HasCommands); ok {\n")
|
||||||
|
b.WriteString("\t\t\tcommands = append(commands, hasCommands.Commands()...)\n")
|
||||||
|
b.WriteString("\t\t}\n")
|
||||||
|
b.WriteString("\t}\n")
|
||||||
|
fmt.Fprintf(&b, "\troot := bonfire.NewRoot(%s, commands, out)\n", strconv.Quote(m.Binary))
|
||||||
|
b.WriteString("\troot.SetArgs(args)\n")
|
||||||
|
b.WriteString("\treturn root.Execute()\n")
|
||||||
|
b.WriteString("}\n")
|
||||||
|
return formatSource([]byte(b.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatePluginsGen(m Manifest) ([]byte, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("// Code generated by summer build. DO NOT EDIT.\n\n")
|
||||||
|
b.WriteString("package main\n\n")
|
||||||
|
if len(m.Plugins) > 0 {
|
||||||
|
b.WriteString("import (\n")
|
||||||
|
for _, p := range m.Plugins {
|
||||||
|
fmt.Fprintf(&b, "\t_ %s\n", strconv.Quote(p.Module))
|
||||||
|
}
|
||||||
|
b.WriteString(")\n\n")
|
||||||
|
}
|
||||||
|
b.WriteString("// PluginIDs is the ordered plugin list from summer.yaml.\n")
|
||||||
|
b.WriteString("var PluginIDs = []string{\n")
|
||||||
|
for _, p := range m.Plugins {
|
||||||
|
fmt.Fprintf(&b, "\t%s,\n", strconv.Quote(p.ID))
|
||||||
|
}
|
||||||
|
b.WriteString("}\n")
|
||||||
|
return formatSource([]byte(b.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSource(src []byte) ([]byte, error) {
|
||||||
|
formatted, err := format.Source(src)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build: format generated go: %w", err)
|
||||||
|
}
|
||||||
|
return formatted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeIfChanged(path string, content []byte) error {
|
||||||
|
existing, err := os.ReadFile(path)
|
||||||
|
if err == nil && bytes.Equal(existing, content) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("build: read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("build: write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
129
internal/build/build_test.go
Normal file
129
internal/build/build_test.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package build
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadHelloManifest(t *testing.T) {
|
||||||
|
m, err := LoadManifest(filepath.Join("..", "..", "examples", "hello", "summer.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.Binary != "hello" {
|
||||||
|
t.Fatalf("binary = %q", m.Binary)
|
||||||
|
}
|
||||||
|
if len(m.Plugins) != 2 || m.Plugins[0].ID != "golem15.hello" || m.Plugins[1].ID != "golem15.greeter" {
|
||||||
|
t.Fatalf("plugins = %+v", m.Plugins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseManifestRejectsDuplicatesAndInvalidPaths(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
t.Run("duplicate id", func(t *testing.T) {
|
||||||
|
path := filepath.Join(dir, "dup.yaml")
|
||||||
|
write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: a.b\n module: example.com/one\n - id: a.b\n module: example.com/two\n")
|
||||||
|
if _, err := LoadManifest(path); err == nil || !strings.Contains(err.Error(), "a.b") {
|
||||||
|
t.Fatalf("want duplicate id error, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid module", func(t *testing.T) {
|
||||||
|
path := filepath.Join(dir, "bad.yaml")
|
||||||
|
write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: a.b\n module: \"example.com/one; rm -rf /\"\n")
|
||||||
|
if _, err := LoadManifest(path); err == nil {
|
||||||
|
t.Fatal("want invalid module error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("path traversal module", func(t *testing.T) {
|
||||||
|
path := filepath.Join(dir, "dotdot.yaml")
|
||||||
|
write(t, path, "module: example.com/app\nbinary: app\nplugins:\n - id: a.b\n module: example.com/../evil\n")
|
||||||
|
if _, err := LoadManifest(path); err == nil {
|
||||||
|
t.Fatal("want invalid module error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateStableQuotedImportsInManifestOrder(t *testing.T) {
|
||||||
|
m := Manifest{
|
||||||
|
Module: "example.com/app",
|
||||||
|
Binary: "hello",
|
||||||
|
Plugins: []Plugin{
|
||||||
|
{ID: "golem15.hello", Module: "example.com/app/plugins/base"},
|
||||||
|
{ID: "golem15.greeter", Module: "example.com/app/plugins/greeter"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
first, err := generatePluginsGen(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := generatePluginsGen(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(first, second) {
|
||||||
|
t.Fatal("generated plugins.gen.go is not byte-stable")
|
||||||
|
}
|
||||||
|
src := string(first)
|
||||||
|
baseAt := strings.Index(src, `example.com/app/plugins/base`)
|
||||||
|
greeterAt := strings.Index(src, `example.com/app/plugins/greeter`)
|
||||||
|
if baseAt < 0 || greeterAt < 0 || baseAt > greeterAt {
|
||||||
|
t.Fatalf("imports not in manifest order:\n%s", src)
|
||||||
|
}
|
||||||
|
if !strings.Contains(src, "\t_ \"example.com/app/plugins/base\"") {
|
||||||
|
t.Fatalf("import is not Go-quoted:\n%s", src)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainSrc, err := generateMain(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := generateMain(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(mainSrc, again) {
|
||||||
|
t.Fatal("generated main.go is not byte-stable")
|
||||||
|
}
|
||||||
|
if !bytes.Contains(mainSrc, []byte(`bonfire.NewRoot("hello"`)) {
|
||||||
|
t.Fatalf("main does not use manifest binary:\n%s", mainSrc)
|
||||||
|
}
|
||||||
|
if bytes.Contains(mainSrc, []byte("examples/hello")) {
|
||||||
|
t.Fatal("generated main hard-codes examples/hello")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteIfChangedSkipsIdenticalBytes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "out.go")
|
||||||
|
content := []byte("package main\n")
|
||||||
|
if err := writeIfChanged(path, content); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := writeIfChanged(path, content); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !info.ModTime().Equal(again.ModTime()) {
|
||||||
|
t.Fatal("identical content rewrote the file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func write(t *testing.T, path, body string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user