From bcd6c9a2e37d8efc644d8d2bb2b9dcc08970ae5f Mon Sep 17 00:00:00 2001 From: huntedbytheirs Date: Wed, 19 Aug 2026 18:37:48 -0400 Subject: [PATCH] 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 --- internal/arch/src/convert.go | 218 +++++++++++++++++++++++++ internal/arch/src/convert_test.go | 258 ++++++++++++++++++++++++++++++ internal/arch/src/frontend.go | 6 +- 3 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 internal/arch/src/convert.go create mode 100644 internal/arch/src/convert_test.go diff --git a/internal/arch/src/convert.go b/internal/arch/src/convert.go new file mode 100644 index 0000000..9353a93 --- /dev/null +++ b/internal/arch/src/convert.go @@ -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: +// +// /.recipe +// /build.sh +// +// v1 fidelity guardrails error out on shapes Zeta cannot represent yet: +// split packages, VCS sources (git+/svn+/hg+/bzr+ protocols, :: 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 `::` fragment form +// (e.g. git::https://…). A plain https:// tarball download is a normal +// source, as is a `name::` 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 +} diff --git a/internal/arch/src/convert_test.go b/internal/arch/src/convert_test.go new file mode 100644 index 0000000..0bac4b0 --- /dev/null +++ b/internal/arch/src/convert_test.go @@ -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) + } + } +} diff --git a/internal/arch/src/frontend.go b/internal/arch/src/frontend.go index 0bae5be..c0d88a1 100644 --- a/internal/arch/src/frontend.go +++ b/internal/arch/src/frontend.go @@ -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() {