feat(arch/src): convert PKGBUILD/.SRCINFO to .recipe + build.sh
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,218 @@
|
||||
// Package src converts Arch Linux source packages (PKGBUILD/.SRCINFO) into
|
||||
// Zeta recipes.
|
||||
package src
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/depmap"
|
||||
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
|
||||
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/zeta"
|
||||
)
|
||||
|
||||
// Convert converts an Arch source package (a PKGBUILD, a .SRCINFO, or a
|
||||
// directory containing either) into a Zeta .recipe with build_system =
|
||||
// "custom" plus a generated build.sh, both written into opts.Output:
|
||||
//
|
||||
// <out>/<name>.recipe
|
||||
// <out>/build.sh
|
||||
//
|
||||
// v1 fidelity guardrails error out on shapes Zeta cannot represent yet:
|
||||
// split packages, VCS sources (git+/svn+/hg+/bzr+ protocols, <vcs>:: URL
|
||||
// fragments, or a dynamic pkgver() function), zero sources and multiple
|
||||
// sources. A nonzero epoch and the dropped metadata fields (makedepends,
|
||||
// optdepends, provides, conflicts, replaces) are warned about on stderr.
|
||||
func Convert(input string, opts registry.Options) error {
|
||||
warn := func(msg string) { fmt.Fprintf(os.Stderr, "warning: %s\n", msg) }
|
||||
|
||||
info, err := Load(input, opts.Arch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: %w", err)
|
||||
}
|
||||
pkgbuildPath, err := resolvePKGBUILD(input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: %w", err)
|
||||
}
|
||||
|
||||
// Fidelity guardrails: v1 covers exactly one package built from exactly
|
||||
// one static tarball source.
|
||||
if len(info.PkgNames) != 1 {
|
||||
return errors.New("convert: split packages not yet supported in v1")
|
||||
}
|
||||
if hasVCS(info, pkgbuildPath) {
|
||||
return errors.New("convert: VCS source not yet supported in v1")
|
||||
}
|
||||
if len(info.Sources) == 0 {
|
||||
return errors.New("convert: no sources not yet supported in v1")
|
||||
}
|
||||
if len(info.Sources) > 1 {
|
||||
return errors.New("convert: multiple sources not yet supported in v1")
|
||||
}
|
||||
|
||||
// Warn-only: an epoch cannot be folded into Zeta versioning, and the
|
||||
// recipe format has no fields for these relations.
|
||||
if info.Epoch != "" && info.Epoch != "0" {
|
||||
warn(fmt.Sprintf("epoch %s is not representable in Zeta versioning", info.Epoch))
|
||||
}
|
||||
for _, f := range []struct {
|
||||
field string
|
||||
vals []string
|
||||
}{
|
||||
{"makedepends", info.Makedepends},
|
||||
{"optdepends", info.Optdepends},
|
||||
{"provides", info.Provides},
|
||||
{"conflicts", info.Conflicts},
|
||||
{"replaces", info.Replaces},
|
||||
} {
|
||||
if len(f.vals) > 0 {
|
||||
warn(fmt.Sprintf("%s dropped (not representable in Zeta)", f.field))
|
||||
}
|
||||
}
|
||||
|
||||
// Deps: map each declared dependency through the Arch→Zeta name map.
|
||||
// Dropped deps are skipped (MapDep already warned).
|
||||
var deps []string
|
||||
for _, d := range info.Depends {
|
||||
if mapped, ok := depmap.MapDep(d, warn); ok {
|
||||
deps = append(deps, mapped)
|
||||
}
|
||||
}
|
||||
|
||||
summary := info.Desc
|
||||
if summary == "" {
|
||||
// recipe.lua requires a non-empty summary.
|
||||
summary = info.PkgNames[0]
|
||||
}
|
||||
|
||||
recipe := &zeta.Recipe{
|
||||
Name: info.PkgNames[0],
|
||||
Version: info.Version,
|
||||
Summary: summary,
|
||||
URL: sourceURL(info.Sources[0]),
|
||||
SHA256: pickSHA256(info.SHA256Sums, warn),
|
||||
Deps: deps,
|
||||
BuildScript: "build.sh",
|
||||
}
|
||||
|
||||
src, err := recipe.Emit()
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: emit recipe: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(opts.Output, 0o755); err != nil {
|
||||
return fmt.Errorf("convert: mkdir %s: %w", opts.Output, err)
|
||||
}
|
||||
recipePath := filepath.Join(opts.Output, recipe.Name+".recipe")
|
||||
if err := os.WriteFile(recipePath, []byte(src), 0o644); err != nil {
|
||||
return fmt.Errorf("convert: write %s: %w", recipePath, err)
|
||||
}
|
||||
|
||||
script, err := Generate(info, pkgbuildPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("convert: generate build.sh: %w", err)
|
||||
}
|
||||
scriptPath := filepath.Join(opts.Output, "build.sh")
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
return fmt.Errorf("convert: write %s: %w", scriptPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolvePKGBUILD returns the file whose text feeds Generate and the
|
||||
// pkgver() scan: the PKGBUILD when one exists, else the .SRCINFO path (whose
|
||||
// content yields no function bodies — stub functions result).
|
||||
func resolvePKGBUILD(input string) (string, error) {
|
||||
st, err := os.Stat(input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !st.IsDir() {
|
||||
return input, nil
|
||||
}
|
||||
pkgbuild := filepath.Join(input, "PKGBUILD")
|
||||
if fileExists(pkgbuild) {
|
||||
return pkgbuild, nil
|
||||
}
|
||||
srcinfo := filepath.Join(input, ".SRCINFO")
|
||||
if fileExists(srcinfo) {
|
||||
return srcinfo, nil
|
||||
}
|
||||
return "", fmt.Errorf("src: %s: neither .SRCINFO nor PKGBUILD found", input)
|
||||
}
|
||||
|
||||
// hasVCS reports whether the source package relies on a VCS source or a
|
||||
// dynamic pkgver() function and therefore cannot be represented in v1.
|
||||
func hasVCS(info *SrcInfo, pkgbuildPath string) bool {
|
||||
for _, s := range info.Sources {
|
||||
if isVCSSource(s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return hasPkgverFunc(pkgbuildPath)
|
||||
}
|
||||
|
||||
// isVCSSource reports whether a makepkg source entry names a VCS checkout:
|
||||
// the git+/svn+/hg+/bzr+ protocol forms or the `<vcs>::<url>` fragment form
|
||||
// (e.g. git::https://…). A plain https:// tarball download is a normal
|
||||
// source, as is a `name::<url>` entry (a download-name prefix, not a VCS).
|
||||
func isVCSSource(s string) bool {
|
||||
for _, prefix := range []string{"git+", "svn+", "hg+", "bzr+"} {
|
||||
if strings.HasPrefix(s, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if frag, _, ok := strings.Cut(s, "::"); ok {
|
||||
switch frag {
|
||||
case "git", "svn", "hg", "bzr", "fossil", "cvs", "darcs", "mtn":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pkgverFuncRE matches a top-level pkgver() function definition in bash
|
||||
// (column 0), the makepkg marker for a dynamically computed version.
|
||||
var pkgverFuncRE = regexp.MustCompile(`(?m)^pkgver\(`)
|
||||
|
||||
func hasPkgverFunc(path string) bool {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return pkgverFuncRE.Match(data)
|
||||
}
|
||||
|
||||
// sourceURL derives the recipe url from a makepkg source entry: the text
|
||||
// after a "name::" prefix when present, otherwise the whole entry verbatim.
|
||||
// The URL scheme is never stripped.
|
||||
func sourceURL(s string) string {
|
||||
if _, rest, ok := strings.Cut(s, "::"); ok {
|
||||
return rest
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// sha256Re matches exactly 64 hex characters, the recipe.lua sha256 shape.
|
||||
var sha256Re = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
|
||||
|
||||
// pickSHA256 returns the source sha256 for the recipe, or "" when absent or
|
||||
// unusable (an empty value, makepkg's "SKIP" marker, or a non-hex64 string —
|
||||
// the latter is warned about because the checksum is dropped).
|
||||
func pickSHA256(sums []string, warn func(string)) string {
|
||||
if len(sums) == 0 {
|
||||
return ""
|
||||
}
|
||||
s := strings.TrimSpace(sums[0])
|
||||
if s == "" || strings.EqualFold(s, "SKIP") {
|
||||
return ""
|
||||
}
|
||||
if !sha256Re.MatchString(s) {
|
||||
warn(fmt.Sprintf("sha256sum %q is not 64 hex characters; checksum dropped", s))
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
srcinfoDir = "../../../testdata/srcinfo"
|
||||
recipeLua = "/home/specter/Public/zeta-toolchain/toolchain/lib/?.lua"
|
||||
)
|
||||
|
||||
// convertMytool runs the full conversion on the mytool fixture directory
|
||||
// into a fresh temp dir and returns that dir plus the emitted recipe and
|
||||
// build.sh contents.
|
||||
func convertMytool(t *testing.T) (out, recipe, script string) {
|
||||
t.Helper()
|
||||
out = t.TempDir()
|
||||
if err := Convert(filepath.Join(srcinfoDir, "mytool"), registry.Options{Output: out}); err != nil {
|
||||
t.Fatalf("Convert(mytool): %v", err)
|
||||
}
|
||||
rb, err := os.ReadFile(filepath.Join(out, "mytool.recipe"))
|
||||
if err != nil {
|
||||
t.Fatalf("mytool.recipe not written: %v", err)
|
||||
}
|
||||
sb, err := os.ReadFile(filepath.Join(out, "build.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("build.sh not written: %v", err)
|
||||
}
|
||||
return out, string(rb), string(sb)
|
||||
}
|
||||
|
||||
// convertStderr runs Convert with os.Stderr redirected into a pipe and
|
||||
// returns the captured output along with the error. Tests using this helper
|
||||
// must not run in parallel.
|
||||
func convertStderr(t *testing.T, input string, opts registry.Options) (string, error) {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
old := os.Stderr
|
||||
os.Stderr = w
|
||||
err = Convert(input, opts)
|
||||
if cerr := w.Close(); cerr != nil && err == nil {
|
||||
err = cerr
|
||||
}
|
||||
os.Stderr = old
|
||||
out, rerr := io.ReadAll(r)
|
||||
if rerr != nil {
|
||||
t.Fatalf("read stderr: %v", rerr)
|
||||
}
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// quoteLua renders a path as a safe single-quoted Lua string literal.
|
||||
func quoteLua(s string) string {
|
||||
return `'` + strings.ReplaceAll(s, `\`, `\\`) + `'`
|
||||
}
|
||||
|
||||
// writePKGBUILD writes a PKGBUILD fixture into a fresh temp dir and returns
|
||||
// the dir (passed as input to Convert).
|
||||
func writePKGBUILD(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "PKGBUILD"), []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write PKGBUILD fixture: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestConvert(t *testing.T) {
|
||||
out, recipe, script := convertMytool(t)
|
||||
|
||||
for _, want := range []string{
|
||||
`build_system = "custom"`,
|
||||
`build_script = "build.sh"`,
|
||||
`name = "mytool"`,
|
||||
} {
|
||||
if !strings.Contains(recipe, want) {
|
||||
t.Errorf("mytool.recipe does not contain %q:\n%s", want, recipe)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(script, `pkgdir="$DESTDIR"`) {
|
||||
t.Errorf("build.sh does not contain pkgdir=\"$DESTDIR\"")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(out, "mytool.recipe")); err != nil {
|
||||
t.Errorf("mytool.recipe missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(out, "build.sh")); err != nil {
|
||||
t.Errorf("build.sh missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertRecipeLoadsInZeta(t *testing.T) {
|
||||
lua, err := exec.LookPath("lua")
|
||||
if err != nil {
|
||||
t.Skip("lua not available; skipping cross-tool validation")
|
||||
}
|
||||
|
||||
out := t.TempDir()
|
||||
if err := Convert(filepath.Join(srcinfoDir, "mytool"), registry.Options{Output: out}); err != nil {
|
||||
t.Fatalf("Convert(mytool): %v", err)
|
||||
}
|
||||
recipePath := filepath.Join(out, "mytool.recipe")
|
||||
|
||||
probe := `package.path="` + recipeLua + `;"..package.path; ` +
|
||||
`local r,e=require("recipe").load(` + quoteLua(recipePath) + `); assert(r,e)`
|
||||
cmd := exec.Command(lua, "-e", probe)
|
||||
if b, cerr := cmd.CombinedOutput(); cerr != nil {
|
||||
t.Fatalf("real zeta-makepkg recipe.load rejected the emitted recipe: %v\n%s", cerr, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertSplitPackage(t *testing.T) {
|
||||
err := Convert(filepath.Join(srcinfoDir, "split"), registry.Options{Output: t.TempDir()})
|
||||
if err == nil {
|
||||
t.Fatal("Convert(split) error = nil, want guardrail error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not yet supported") {
|
||||
t.Errorf("Convert(split) error = %q, want it to contain %q", err, "not yet supported")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "split packages not yet supported in v1") {
|
||||
t.Errorf("Convert(split) error = %q, want the split guardrail message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertVCS(t *testing.T) {
|
||||
err := Convert(filepath.Join(srcinfoDir, "vcs"), registry.Options{Output: t.TempDir()})
|
||||
if err == nil {
|
||||
t.Fatal("Convert(vcs) error = nil, want guardrail error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not yet supported") {
|
||||
t.Errorf("Convert(vcs) error = %q, want it to contain %q", err, "not yet supported")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "VCS source not yet supported in v1") {
|
||||
t.Errorf("Convert(vcs) error = %q, want the VCS guardrail message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertEpochWarns(t *testing.T) {
|
||||
dir := writePKGBUILD(t, `pkgname=epo
|
||||
pkgver=1.0
|
||||
pkgrel=1
|
||||
epoch=1
|
||||
pkgdesc="Epoch fixture"
|
||||
arch=(x86_64)
|
||||
source=("epo-1.0.tar.gz")
|
||||
`)
|
||||
stderr, err := convertStderr(t, dir, registry.Options{Output: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatalf("Convert(epoch) error = %v, want nil (epoch warns only)", err)
|
||||
}
|
||||
if !strings.Contains(stderr, "epoch") {
|
||||
t.Errorf("stderr = %q, want a warning mentioning epoch", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertNamedSourceURL(t *testing.T) {
|
||||
dir := writePKGBUILD(t, `pkgname=namedsrc
|
||||
pkgver=1.0
|
||||
pkgrel=1
|
||||
pkgdesc="Named source fixture"
|
||||
arch=(x86_64)
|
||||
source=("name::https://example.com/x.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
`)
|
||||
out := t.TempDir()
|
||||
if err := Convert(dir, registry.Options{Output: out}); err != nil {
|
||||
t.Fatalf("Convert(named source) error = %v, want nil", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(out, "namedsrc.recipe"))
|
||||
if err != nil {
|
||||
t.Fatalf("namedsrc.recipe not written: %v", err)
|
||||
}
|
||||
const wantLine = `url = "https://example.com/x.tar.gz",`
|
||||
if !strings.Contains(string(content), wantLine) {
|
||||
t.Errorf("recipe url = %q, want the line %q (scheme preserved, name:: stripped):\n%s",
|
||||
string(content), wantLine, content)
|
||||
}
|
||||
if strings.Contains(string(content), "name::") {
|
||||
t.Errorf("recipe still contains the name:: prefix:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertMissingInput(t *testing.T) {
|
||||
err := Convert(filepath.Join(t.TempDir(), "nope"), registry.Options{Output: t.TempDir()})
|
||||
if err == nil {
|
||||
t.Fatal("Convert(missing) error = nil, want error for nonexistent input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertZeroSources(t *testing.T) {
|
||||
dir := writePKGBUILD(t, `pkgname=nosrc
|
||||
pkgver=1.0
|
||||
pkgrel=1
|
||||
pkgdesc="No sources fixture"
|
||||
arch=(x86_64)
|
||||
`)
|
||||
err := Convert(dir, registry.Options{Output: t.TempDir()})
|
||||
if err == nil {
|
||||
t.Fatal("Convert(no sources) error = nil, want guardrail error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no sources not yet supported in v1") {
|
||||
t.Errorf("Convert(no sources) error = %q, want the zero-source guardrail message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertMultiSource(t *testing.T) {
|
||||
dir := writePKGBUILD(t, `pkgname=multisrc
|
||||
pkgver=1.0
|
||||
pkgrel=1
|
||||
pkgdesc="Multi source fixture"
|
||||
arch=(x86_64)
|
||||
source=("a-1.0.tar.gz" "b-1.0.tar.gz")
|
||||
sha256sums=('SKIP' 'SKIP')
|
||||
`)
|
||||
err := Convert(dir, registry.Options{Output: t.TempDir()})
|
||||
if err == nil {
|
||||
t.Fatal("Convert(multi source) error = nil, want guardrail error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "multiple sources not yet supported in v1") {
|
||||
t.Errorf("Convert(multi source) error = %q, want the multi-source guardrail message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertDroppedMetadataWarns(t *testing.T) {
|
||||
dir := writePKGBUILD(t, `pkgname=meta
|
||||
pkgver=1.0
|
||||
pkgrel=1
|
||||
pkgdesc="Dropped metadata fixture"
|
||||
arch=(x86_64)
|
||||
source=("meta-1.0.tar.gz")
|
||||
makedepends=('make')
|
||||
optdepends=('bonus: optional extra')
|
||||
provides=('virt')
|
||||
conflicts=('other')
|
||||
replaces=('old')
|
||||
`)
|
||||
stderr, err := convertStderr(t, dir, registry.Options{Output: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatalf("Convert(dropped metadata) error = %v, want nil (warns only)", err)
|
||||
}
|
||||
for _, field := range []string{"makedepends", "optdepends", "provides", "conflicts", "replaces"} {
|
||||
want := "warning: " + field + " dropped (not representable in Zeta)"
|
||||
if !strings.Contains(stderr, want) {
|
||||
t.Errorf("stderr = %q, want warning %q", stderr, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
package src
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
|
||||
)
|
||||
|
||||
@@ -13,9 +11,9 @@ type frontend struct{}
|
||||
|
||||
func (frontend) Name() string { return "arch-src" }
|
||||
|
||||
// Convert delegates to the package-level converter (convert.go).
|
||||
func (frontend) Convert(input string, opts registry.Options) error {
|
||||
// Implemented in a later todo (PKGBUILD/.SRCINFO -> .recipe + build.sh).
|
||||
return errors.New("not implemented")
|
||||
return Convert(input, opts)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
Reference in New Issue
Block a user