feat(zeta): recipe model, validation, .recipe emitter
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <[email protected]>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
// Package zeta models Zeta package artifacts: package.lua manifests
|
||||
// (manifest.go) and zeta-makepkg .recipe source-build recipes (recipe.go).
|
||||
package zeta
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Recipe models a declarative Zeta .recipe file — the input format for
|
||||
// zeta-makepkg source builds. It is purely data; zeta-makepkg handles the
|
||||
// *how* (fetch, extract, build, package).
|
||||
//
|
||||
// v1 of zeta-reconstruct always uses build_system = "custom" wrapping an
|
||||
// arbitrary PKGBUILD-derived build.sh, so BuildScript is always required.
|
||||
type Recipe struct {
|
||||
Name string
|
||||
Version string
|
||||
Summary string
|
||||
URL string
|
||||
SHA256 string // source checksum; empty means "omit the line" (see Emit)
|
||||
Deps []string // runtime dependencies
|
||||
BuildScript string // required: build_system is always "custom" in v1
|
||||
}
|
||||
|
||||
var (
|
||||
// Mirrors path.sanitize_name (zeta-toolchain/toolchain/lib/path.lua:94-100):
|
||||
// alphanumeric, dots, underscores, plus, hyphen — no leading dot.
|
||||
recipeNameRE = regexp.MustCompile(`^[\w.+\-]+$`)
|
||||
// Mirrors recipe.lua HEX64 (toolchain/lib/recipe.lua:37).
|
||||
recipeHex64RE = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
)
|
||||
|
||||
func recipeNameValid(name string) bool {
|
||||
if name == "" || strings.HasPrefix(name, ".") {
|
||||
return false
|
||||
}
|
||||
return recipeNameRE.MatchString(name)
|
||||
}
|
||||
|
||||
// Validate mirrors zeta-toolchain/toolchain/lib/recipe.lua normalize()
|
||||
// (:43-173) for the fields zeta-reconstruct v1 emits. build_system is fixed
|
||||
// to "custom", so a non-empty BuildScript is always required (:121-123).
|
||||
func (r *Recipe) Validate() error {
|
||||
if !recipeNameValid(r.Name) {
|
||||
return fmt.Errorf("recipe: missing or invalid package name")
|
||||
}
|
||||
if r.Version == "" {
|
||||
return fmt.Errorf("recipe %q: version is required and must be a non-empty string", r.Name)
|
||||
}
|
||||
if r.Summary == "" {
|
||||
return fmt.Errorf("recipe %q: summary is required", r.Name)
|
||||
}
|
||||
if r.URL == "" {
|
||||
return fmt.Errorf("recipe %q: url is required", r.Name)
|
||||
}
|
||||
if r.SHA256 != "" && !recipeHex64RE.MatchString(strings.ToLower(r.SHA256)) {
|
||||
return fmt.Errorf("recipe %q: sha256 must be exactly 64 hex characters or nil", r.Name)
|
||||
}
|
||||
for _, d := range r.Deps {
|
||||
if d == "" {
|
||||
return fmt.Errorf("recipe %q: each dep must be a non-empty string", r.Name)
|
||||
}
|
||||
}
|
||||
if r.BuildScript == "" {
|
||||
return fmt.Errorf("recipe %q: build_script is required when build_system is 'custom'", r.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// escapeRecipeString escapes a value for a double-quoted Lua string literal.
|
||||
// Only double quotes and newlines are escaped, matching the emitter used by
|
||||
// zeta-toolchain's packager.lua.
|
||||
func escapeRecipeString(s string) string {
|
||||
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
return s
|
||||
}
|
||||
|
||||
// Emit renders the recipe as .recipe Lua text: 2-space indent, field keys
|
||||
// padded to width 13 before " = " (replicating the alignment of recipe.lua
|
||||
// and examples/custom-demo/custom.recipe), fields in order name, version,
|
||||
// summary, url, [sha256,] deps, build_system, build_script.
|
||||
//
|
||||
// CRITICAL: the sha256 line is omitted ENTIRELY when SHA256 is empty.
|
||||
// recipe.lua checks `raw.sha256 ~= nil` (toolchain/lib/recipe.lua:74), so an
|
||||
// empty string is treated as present and fails the 64-hex check. Never emit
|
||||
// `sha256 = ""`.
|
||||
func (r *Recipe) Emit() (string, error) {
|
||||
if err := r.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("return {\n")
|
||||
fmt.Fprintf(&b, " %-13s= \"%s\",\n", "name", r.Name)
|
||||
fmt.Fprintf(&b, " %-13s= \"%s\",\n", "version", r.Version)
|
||||
fmt.Fprintf(&b, " %-13s= \"%s\",\n", "summary", escapeRecipeString(r.Summary))
|
||||
fmt.Fprintf(&b, " %-13s= \"%s\",\n", "url", r.URL)
|
||||
if r.SHA256 != "" {
|
||||
// recipe.lua normalizes sha256 to lowercase on load (:78).
|
||||
fmt.Fprintf(&b, " %-13s= \"%s\",\n", "sha256", strings.ToLower(r.SHA256))
|
||||
}
|
||||
if len(r.Deps) == 0 {
|
||||
b.WriteString(" deps = {},\n")
|
||||
} else {
|
||||
quoted := make([]string, len(r.Deps))
|
||||
for i, d := range r.Deps {
|
||||
quoted[i] = `"` + escapeRecipeString(d) + `"`
|
||||
}
|
||||
fmt.Fprintf(&b, " %-13s= { %s },\n", "deps", strings.Join(quoted, ", "))
|
||||
}
|
||||
fmt.Fprintf(&b, " %-13s= \"custom\",\n", "build_system")
|
||||
fmt.Fprintf(&b, " %-13s= \"%s\",\n", "build_script", r.BuildScript)
|
||||
b.WriteString("}\n")
|
||||
return b.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package zeta
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRecipe mirrors the zeta-toolchain recipe.lua validation rules:
|
||||
// name (sanitize_name), version, summary, url required non-empty;
|
||||
// sha256 empty-or-64-hex; deps non-empty strings; build_script required
|
||||
// (build_system is always "custom" in v1).
|
||||
func TestRecipe(t *testing.T) {
|
||||
valid := func() *Recipe {
|
||||
return &Recipe{
|
||||
Name: "mytool",
|
||||
Version: "1.0",
|
||||
Summary: "Example custom-build package",
|
||||
URL: "mytool-1.0.tar.gz",
|
||||
SHA256: "",
|
||||
Deps: []string{"libz"},
|
||||
BuildScript: "build.sh",
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(r *Recipe)
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid recipe", func(r *Recipe) {}, false},
|
||||
{"empty name", func(r *Recipe) { r.Name = "" }, true},
|
||||
{"name with slash", func(r *Recipe) { r.Name = "bad/name" }, true},
|
||||
{"name with leading dot", func(r *Recipe) { r.Name = ".hidden" }, true},
|
||||
{"empty version", func(r *Recipe) { r.Version = "" }, true},
|
||||
{"empty summary", func(r *Recipe) { r.Summary = "" }, true},
|
||||
{"empty url", func(r *Recipe) { r.URL = "" }, true},
|
||||
{"empty build_script", func(r *Recipe) { r.BuildScript = "" }, true},
|
||||
{"bad sha256", func(r *Recipe) { r.SHA256 = "zzz" }, true},
|
||||
{"short sha256 (63 hex)", func(r *Recipe) { r.SHA256 = strings.Repeat("a", 63) }, true},
|
||||
{"empty sha256 is allowed", func(r *Recipe) { r.SHA256 = "" }, false},
|
||||
{"64-hex sha256 allowed", func(r *Recipe) { r.SHA256 = strings.Repeat("f", 64) }, false},
|
||||
{"empty dep string", func(r *Recipe) { r.Deps = []string{"libz", ""} }, true},
|
||||
{"no deps allowed", func(r *Recipe) { r.Deps = nil }, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := valid()
|
||||
tt.mutate(r)
|
||||
err := r.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeEmit(t *testing.T) {
|
||||
// Byte-exact render for the canonical custom recipe. Field keys are
|
||||
// padded to width 13 before " = ", matching recipe.lua and the
|
||||
// custom-demo/custom.recipe example.
|
||||
canonical := &Recipe{
|
||||
Name: "mytool",
|
||||
Version: "1.0",
|
||||
Summary: "Example custom-build package",
|
||||
URL: "mytool-1.0.tar.gz",
|
||||
SHA256: "",
|
||||
Deps: nil,
|
||||
BuildScript: "build.sh",
|
||||
}
|
||||
want := `return {
|
||||
name = "mytool",
|
||||
version = "1.0",
|
||||
summary = "Example custom-build package",
|
||||
url = "mytool-1.0.tar.gz",
|
||||
deps = {},
|
||||
build_system = "custom",
|
||||
build_script = "build.sh",
|
||||
}
|
||||
`
|
||||
out, err := canonical.Emit()
|
||||
if err != nil {
|
||||
t.Fatalf("Emit() error = %v", err)
|
||||
}
|
||||
if out != want {
|
||||
t.Fatalf("Emit() mismatch:\n--- want ---\n%s\n--- got ---\n%s", want, out)
|
||||
}
|
||||
|
||||
// Critical: empty SHA256 must OMIT the sha256 line entirely. recipe.lua
|
||||
// treats sha256 = "" as present (raw.sha256 ~= nil) and fails the
|
||||
// 64-hex check, so the line must never be emitted empty.
|
||||
if strings.Contains(out, "sha256") {
|
||||
t.Fatalf("Emit() with empty SHA256 must not contain a sha256 line, got:\n%s", out)
|
||||
}
|
||||
|
||||
// Non-empty SHA256 emits the line after url and before deps.
|
||||
withSHA := *canonical
|
||||
withSHA.SHA256 = strings.Repeat("a", 64)
|
||||
outSHA, err := withSHA.Emit()
|
||||
if err != nil {
|
||||
t.Fatalf("Emit() with sha256 error = %v", err)
|
||||
}
|
||||
shaLine := ` sha256 = "` + strings.Repeat("a", 64) + `",`
|
||||
if !strings.Contains(outSHA, shaLine) {
|
||||
t.Fatalf("Emit() missing aligned sha256 line, got:\n%s", outSHA)
|
||||
}
|
||||
if !(strings.Index(outSHA, "url") < strings.Index(outSHA, "sha256") &&
|
||||
strings.Index(outSHA, "sha256") < strings.Index(outSHA, "deps")) {
|
||||
t.Fatalf("Emit() sha256 not between url and deps, got:\n%s", outSHA)
|
||||
}
|
||||
|
||||
// deps render on one line; empty deps render as {}.
|
||||
withDeps := *canonical
|
||||
withDeps.Deps = []string{"libz", "libfoo"}
|
||||
outDeps, err := withDeps.Emit()
|
||||
if err != nil {
|
||||
t.Fatalf("Emit() with deps error = %v", err)
|
||||
}
|
||||
if !strings.Contains(outDeps, ` deps = { "libz", "libfoo" },`) {
|
||||
t.Fatalf("Emit() deps line mismatch, got:\n%s", outDeps)
|
||||
}
|
||||
|
||||
// Escape double quotes and newlines in summary and deps values.
|
||||
escaped := *canonical
|
||||
escaped.Summary = "say \"hi\"\nsecond line"
|
||||
escaped.Deps = []string{"say \"hi\""}
|
||||
outEsc, err := escaped.Emit()
|
||||
if err != nil {
|
||||
t.Fatalf("Emit() with escapes error = %v", err)
|
||||
}
|
||||
if !strings.Contains(outEsc, ` summary = "say \"hi\"\nsecond line",`) {
|
||||
t.Fatalf("Emit() summary escape mismatch, got:\n%s", outEsc)
|
||||
}
|
||||
if !strings.Contains(outEsc, ` deps = { "say \"hi\"" },`) {
|
||||
t.Fatalf("Emit() deps escape mismatch, got:\n%s", outEsc)
|
||||
}
|
||||
|
||||
// Emit of an invalid recipe returns the validation error.
|
||||
invalid := *canonical
|
||||
invalid.Name = ""
|
||||
if _, err := invalid.Emit(); err == nil {
|
||||
t.Fatal("Emit() of invalid recipe should return an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeGolden(t *testing.T) {
|
||||
r := &Recipe{
|
||||
Name: "mytool",
|
||||
Version: "1.0",
|
||||
Summary: "Example custom-build package",
|
||||
URL: "mytool-1.0.tar.gz",
|
||||
SHA256: "",
|
||||
Deps: nil,
|
||||
BuildScript: "build.sh",
|
||||
}
|
||||
out, err := r.Emit()
|
||||
if err != nil {
|
||||
t.Fatalf("Emit() error = %v", err)
|
||||
}
|
||||
|
||||
// Golden files live at the repo root so todos 5/12/14 can share them.
|
||||
golden := filepath.Join("..", "..", "testdata", "golden", "mytool.recipe")
|
||||
|
||||
if os.Getenv("UPDATE_GOLDEN") == "1" {
|
||||
if err := os.MkdirAll(filepath.Dir(golden), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(golden, []byte(out), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
t.Logf("wrote golden %s", golden)
|
||||
return
|
||||
}
|
||||
|
||||
want, err := os.ReadFile(golden)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile golden %s: %v (run with UPDATE_GOLDEN=1 to create)", golden, err)
|
||||
}
|
||||
if string(want) != out {
|
||||
t.Fatalf("golden mismatch:\n--- want ---\n%s\n--- got ---\n%s", want, out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user