diff --git a/internal/arch/src/buildscript.go b/internal/arch/src/buildscript.go new file mode 100644 index 0000000..ac0ed3f --- /dev/null +++ b/internal/arch/src/buildscript.go @@ -0,0 +1,193 @@ +// Package src converts Arch Linux source packages (PKGBUILD/.SRCINFO) into +// Zeta recipes. +package src + +import ( + "errors" + "fmt" + "os" + "regexp" + "strings" +) + +// SrcInfo holds the parsed metadata of an Arch source package, produced by +// the .SRCINFO parser / PKGBUILD fallback (todo 10). The field layout is +// fixed by the plan; the parallel task's srcinfo.go populates this struct. +type SrcInfo struct { + PkgBase string + PkgNames []string + Version string + Rel string + Epoch string + Desc string + URL string + Arch []string + Depends []string + Makedepends []string + Optdepends []string + Provides []string + Conflicts []string + Replaces []string + Sources []string + SHA256Sums []string +} + +// Generate emits a self-contained POSIX-sh build script (build.sh) that +// reproduces the PKGBUILD's prepare/build/package steps under zeta-makepkg's +// custom build contract: +// +// cd && DESTDIR= sh build.sh +// +// so $PWD at script start is the extracted source root and $DESTDIR is the +// staging tree. Arch's package() installs into $pkgdir; mapping +// pkgdir="$DESTDIR" is the whole trick. +// +// Function bodies declared by the PKGBUILD at pkgbuildPath are embedded +// verbatim; missing functions become no-op stubs and are not called. If an +// embedded body uses bash-only constructs, a warning is printed to stderr but +// the script is still emitted. +func Generate(s *SrcInfo, pkgbuildPath string) (string, error) { + if s == nil { + return "", errors.New("arch/src: nil SrcInfo") + } + raw, err := os.ReadFile(pkgbuildPath) + if err != nil { + return "", fmt.Errorf("arch/src: read PKGBUILD %s: %w", pkgbuildPath, err) + } + funcs, err := extractFunctions(string(raw)) + if err != nil { + return "", err + } + if hasBashOnly(funcs) { + fmt.Fprintln(os.Stderr, "warning: build.sh embeds bash-only constructs; may need manual adjustment under POSIX sh") + } + + var b strings.Builder + b.WriteString("#!/bin/sh\n") + b.WriteString("set -eu\n") + b.WriteString("\n") + b.WriteString("pkgname=" + shQuote(pkgName(s)) + "\n") + b.WriteString("pkgver=" + shQuote(s.Version) + "\n") + b.WriteString("pkgrel=" + shQuote(s.Rel) + "\n") + b.WriteString("arch=" + shQuote(strings.Join(s.Arch, " ")) + "\n") + b.WriteString("url=" + shQuote(s.URL) + "\n") + b.WriteString("\n") + b.WriteString("srcdir=\"$PWD\"\n") + b.WriteString("pkgdir=\"$DESTDIR\"\n") + b.WriteString("\n") + b.WriteString("msg() { :; }\n") + b.WriteString("\n") + + var calls []string + for _, f := range funcs { + if f.present { + b.WriteString(f.body) + calls = append(calls, f.name) + } else { + fmt.Fprintf(&b, "%s() { :; }\n", f.name) + } + b.WriteString("\n") + } + for _, name := range calls { + b.WriteString(name + "\n") + } + return b.String(), nil +} + +// pkgName prefers the pkgname section value; fall back to pkgbase. +func pkgName(s *SrcInfo) string { + if len(s.PkgNames) > 0 { + return s.PkgNames[0] + } + return s.PkgBase +} + +// shQuote single-quotes a value for safe embedding in sh source. +func shQuote(v string) string { + return "'" + strings.ReplaceAll(v, "'", `'\''`) + "'" +} + +// pkgFunc is one extracted PKGBUILD function: its verbatim text (including +// the "name() {" opening and closing "}") and whether it was present. +type pkgFunc struct { + name string + body string + present bool +} + +// extractFunctions pulls prepare/build/package bodies out of a PKGBUILD. +// Each body runs from the "name() {" opener to the first line starting with +// "}" at column 0, the standard makepkg layout heuristic. +func extractFunctions(src string) ([]pkgFunc, error) { + names := []string{"prepare", "build", "package"} + out := make([]pkgFunc, 0, len(names)) + for _, name := range names { + body, present, err := extractFunc(src, name) + if err != nil { + return nil, err + } + out = append(out, pkgFunc{name: name, body: body, present: present}) + } + return out, nil +} + +func extractFunc(src, name string) (string, bool, error) { + startRe := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(name) + `\(\s*\)\s*\{`) + loc := startRe.FindStringIndex(src) + if loc == nil { + return "", false, nil + } + end := findClosingBrace(src, loc[1]) + if end < 0 { + return "", false, fmt.Errorf("arch/src: %s() { has no closing '}' at column 0", name) + } + return src[loc[0]:end], true, nil +} + +// findClosingBrace returns the offset just past the first line starting with +// "}" (column 0), scanning from from. Returns -1 if none is found. +func findClosingBrace(src string, from int) int { + lineStart := from + for lineStart <= len(src) { + lineEnd := strings.IndexByte(src[lineStart:], '\n') + if lineEnd < 0 { + if strings.HasPrefix(src[lineStart:], "}") { + return len(src) + } + return -1 + } + line := src[lineStart : lineStart+lineEnd] + if strings.HasPrefix(line, "}") { + return lineStart + lineEnd + 1 // include the newline + } + lineStart += lineEnd + 1 + } + return -1 +} + +// bashOnlyPatterns matches constructs that are valid in bash but not POSIX sh. +var bashOnlyPatterns = []*regexp.Regexp{ + regexp.MustCompile(`\[\[`), // [[ test ]] + regexp.MustCompile(`\$\{[A-Za-z_][A-Za-z0-9_]*\[[^]\n]+\]\}`), // ${arr[@]} / ${arr[0]} + regexp.MustCompile(`(^|[^A-Za-z0-9_])local[ \t]+`), // local var + regexp.MustCompile(`(^|[^A-Za-z0-9_])declare[ \t]+`), // declare -a ... + regexp.MustCompile(`(^|[ \t;&])[A-Za-z_][A-Za-z0-9_]*=\(`), // arr=(...) + regexp.MustCompile(`<\(|>\(`), // process substitution + regexp.MustCompile(`\$\'`), // $'\n' ANSI-C quoting +} + +// hasBashOnly reports whether any extracted function body uses a bash-only +// construct. +func hasBashOnly(funcs []pkgFunc) bool { + for _, f := range funcs { + if !f.present { + continue + } + for _, re := range bashOnlyPatterns { + if re.MatchString(f.body) { + return true + } + } + } + return false +} diff --git a/internal/arch/src/buildscript_test.go b/internal/arch/src/buildscript_test.go new file mode 100644 index 0000000..3ce9d7e --- /dev/null +++ b/internal/arch/src/buildscript_test.go @@ -0,0 +1,146 @@ +package src + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const ( + mytoolSRCINFO = "../../../testdata/srcinfo/mytool/.SRCINFO" + mytoolPKGBUILD = "../../../testdata/srcinfo/mytool/PKGBUILD" + goldenPath = "../../../testdata/golden/mytool.build.sh" +) + +// minimalSrcInfo is a test-local .SRCINFO reader. The real parser (todo 10, +// srcinfo.go) may not exist yet when this test compiles, so the test keeps a +// tiny reader for the fields Generate needs. It mirrors the documented SrcInfo +// layout so it converges with the real parser. +func minimalSrcInfo(t *testing.T, path string) *SrcInfo { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + s := &SrcInfo{} + for _, line := range strings.Split(string(data), "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok { + continue + } + key, value = strings.TrimSpace(key), strings.TrimSpace(value) + switch key { + case "pkgbase": + s.PkgBase = value + case "pkgname": + s.PkgNames = append(s.PkgNames, value) + case "pkgver": + s.Version = value + case "pkgrel": + s.Rel = value + case "arch": + s.Arch = append(s.Arch, value) + case "url": + s.URL = value + } + } + return s +} + +func generateMytool(t *testing.T) string { + t.Helper() + out, err := Generate(minimalSrcInfo(t, mytoolSRCINFO), mytoolPKGBUILD) + if err != nil { + t.Fatalf("Generate: %v", err) + } + return out +} + +func TestBuildScriptGolden(t *testing.T) { + out := generateMytool(t) + + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.WriteFile(goldenPath, []byte(out), 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } + t.Logf("wrote golden %s", goldenPath) + return + } + + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden (run: UPDATE_GOLDEN=1 go test ./internal/arch/src/ -run TestBuildScript): %v", err) + } + if string(want) != out { + t.Errorf("generated build.sh does not match golden %s", goldenPath) + for i, line := range strings.Split(string(want), "\n") { + gotLine := "" + if ls := strings.Split(out, "\n"); i < len(ls) { + gotLine = ls[i] + } + if line != gotLine { + t.Errorf("first diff at line %d:\n golden: %q\n got: %q", i+1, line, gotLine) + break + } + } + } +} + +func TestBuildScriptPassesShSyntax(t *testing.T) { + sh, err := exec.LookPath("sh") + if err != nil { + t.Skip("sh not found in PATH") + } + out := generateMytool(t) + script := filepath.Join(t.TempDir(), "build.sh") + if err := os.WriteFile(script, []byte(out), 0o755); err != nil { + t.Fatalf("write script: %v", err) + } + cmd := exec.Command(sh, "-n", script) + if output, err := cmd.CombinedOutput(); err != nil { + t.Errorf("sh -n %s failed: %v\n%s", script, err, output) + } +} + +func TestBuildScriptContainsDestdir(t *testing.T) { + out := generateMytool(t) + if !strings.Contains(out, `pkgdir="$DESTDIR"`) { + t.Errorf("generated script missing pkgdir=\"$DESTDIR\"") + } + if !strings.Contains(out, "$DESTDIR") { + t.Errorf("generated script missing literal $DESTDIR") + } +} + +func TestBuildScriptNoFunctions(t *testing.T) { + dir := t.TempDir() + pkgbuild := filepath.Join(dir, "PKGBUILD") + if err := os.WriteFile(pkgbuild, []byte("pkgname=nofunc\npkgver=2.0\npkgrel=1\narch=(x86_64)\n"), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + si := &SrcInfo{PkgNames: []string{"nofunc"}, Version: "2.0", Rel: "1", Arch: []string{"x86_64"}} + out, err := Generate(si, pkgbuild) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if !strings.Contains(out, `pkgdir="$DESTDIR"`) { + t.Errorf("script without functions must still define pkgdir=\"$DESTDIR\"") + } + if !strings.Contains(out, "package") { + t.Errorf("script without functions must still reference package (stub)") + } +} + +func TestBuildScriptEmbedsBuildBody(t *testing.T) { + out := generateMytool(t) + const wantBuild = "build() {\n cd \"$srcdir\"\n ./configure --prefix=/usr\n make\n}" + const wantPackage = "package() {\n cd \"$srcdir\"\n make DESTDIR=\"$pkgdir\" install\n}" + if !strings.Contains(out, wantBuild) { + t.Errorf("generated script does not embed the fixture's build() body verbatim") + } + if !strings.Contains(out, wantPackage) { + t.Errorf("generated script does not embed the fixture's package() body verbatim") + } +} diff --git a/testdata/golden/mytool.build.sh b/testdata/golden/mytool.build.sh new file mode 100644 index 0000000..ea0d399 --- /dev/null +++ b/testdata/golden/mytool.build.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -eu + +pkgname='mytool' +pkgver='1.0' +pkgrel='1' +arch='x86_64' +url='https://example.org' + +srcdir="$PWD" +pkgdir="$DESTDIR" + +msg() { :; } + +prepare() { :; } + +build() { + cd "$srcdir" + ./configure --prefix=/usr + make +} + +package() { + cd "$srcdir" + make DESTDIR="$pkgdir" install +} + +build +package