feat(arch/src): .SRCINFO parser + PKGBUILD fallback

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
This commit is contained in:
2026-08-19 18:31:59 -04:00
co-authored by Sisyphus
parent 8267b45888
commit 44c63b5cf7
2 changed files with 592 additions and 0 deletions
+299
View File
@@ -0,0 +1,299 @@
package src
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// ParseSRCINFO parses makepkg .SRCINFO content: lines of "key = value"
// grouped into a pkgbase section and one or more pkgname sections. Keys in
// the pkgbase section are inherited by every package; the first pkgname
// section extends/overrides them (v1 targets single-package recipes, so
// later pkgname sections only contribute their name to PkgNames — the
// converter rejects split packages before they get here).
//
// Repeatable keys (arch, depends, makedepends, optdepends, provides,
// conflicts, replaces, source, sha256sums) accumulate; single-value keys
// (pkgver, pkgrel, epoch, pkgdesc, url) take the last occurrence. Keys
// suffixed with _<arch> (e.g. depends_x86_64) are folded into their base
// key only when the suffix matches the requested arch; suffixes for other
// arches are ignored. An empty arch defaults to x86_64.
//
// ParseSRCINFO returns an error when no pkgver is present.
func ParseSRCINFO(src string, arch string) (*SrcInfo, error) {
if arch == "" {
arch = "x86_64"
}
info := &SrcInfo{}
pkgnameSections := 0
firstSection := true
for _, line := range strings.Split(src, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
eq := strings.Index(line, "=")
if eq < 0 {
continue // malformed line: skip
}
key := strings.TrimSpace(line[:eq])
value := strings.TrimSpace(line[eq+1:])
switch key {
case "pkgbase":
info.PkgBase = value
case "pkgname":
info.PkgNames = append(info.PkgNames, value)
pkgnameSections++
firstSection = false
default:
// Keys in pkgname sections beyond the first are ignored: v1
// targets single-package recipes.
if !firstSection && pkgnameSections > 1 {
continue
}
if isSingleKey(key) {
applyKey(info, key, value)
continue
}
base, ok := foldArchKey(key, arch)
if ok {
applyKey(info, base, value)
}
// else: unknown key or a different arch's variant — ignore.
}
}
if info.Version == "" {
return nil, errors.New("srcinfo: missing pkgver")
}
return info, nil
}
// isSingleKey reports whether key is a single-value (last-wins) .SRCINFO
// key that never carries an _<arch> suffix.
func isSingleKey(key string) bool {
switch key {
case "pkgver", "pkgrel", "epoch", "pkgdesc", "url", "arch":
return true
}
return false
}
// applyKey records one .SRCINFO key=value into info. Single-value keys take
// the last occurrence; repeatable keys accumulate.
func applyKey(info *SrcInfo, key, value string) {
switch key {
case "pkgver":
info.Version = value
case "pkgrel":
info.Rel = value
case "epoch":
info.Epoch = value
case "pkgdesc":
info.Desc = value
case "url":
info.URL = value
case "arch":
info.Arch = append(info.Arch, value)
case "depends":
info.Depends = append(info.Depends, value)
case "makedepends":
info.Makedepends = append(info.Makedepends, value)
case "optdepends":
info.Optdepends = append(info.Optdepends, value)
case "provides":
info.Provides = append(info.Provides, value)
case "conflicts":
info.Conflicts = append(info.Conflicts, value)
case "replaces":
info.Replaces = append(info.Replaces, value)
case "source":
info.Sources = append(info.Sources, value)
case "sha256sums":
info.SHA256Sums = append(info.SHA256Sums, value)
}
}
// archVariantKeys are the .SRCINFO keys that may carry an _<arch> suffix.
var archVariantKeys = map[string]bool{
"source": true, "depends": true, "makedepends": true, "optdepends": true,
"provides": true, "conflicts": true, "replaces": true,
"sha256sums": true, "md5sums": true, "sha1sums": true, "sha224sums": true,
"sha384sums": true, "sha512sums": true, "b2sums": true,
}
// foldArchKey maps a .SRCINFO key to the base key it contributes to, and
// reports whether it applies to the requested arch. A bare key applies to
// every arch; a `key_<arch>` key applies only when <arch> matches; a
// `key_<other>` key folds to nothing.
func foldArchKey(key, arch string) (string, bool) {
if base, ok := strings.CutSuffix(key, "_"+arch); ok && archVariantKeys[base] {
return base, true
}
if archVariantKeys[key] {
return key, true
}
if base, _, ok := strings.Cut(key, "_"); ok && archVariantKeys[base] {
return "", false
}
return "", false
}
// precompiled assignment regexes. PKGBUILDs are bash; these regexes match
// only top-level `key=value` declarations at column 0, so assignments
// inside function bodies (indented) and `key() {` function definitions are
// never evaluated.
var (
reScalar = map[string]*regexp.Regexp{}
reArray = map[string]*regexp.Regexp{}
)
func init() {
for _, key := range []string{"pkgbase", "pkgname", "pkgver", "pkgrel", "epoch", "pkgdesc", "url"} {
reScalar[key] = regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `\s*=\s*(?:'([^']*)'|"([^"]*)"|([^\s'"]+))`)
}
for _, key := range []string{"pkgname", "arch", "depends", "makedepends", "optdepends", "provides", "conflicts", "replaces", "source", "sha256sums"} {
reArray[key] = regexp.MustCompile(`(?ms)^` + regexp.QuoteMeta(key) + `\s*=\s*\(([^)]*)\)`)
}
}
// ParsePKGBUILD best-effort extracts declaration values from a PKGBUILD by
// regex-matching top-level `key=value` assignments. It NEVER executes bash:
// function bodies, comments, conditionals and variables are left untouched,
// and values referencing variables such as $pkgname are kept verbatim.
// Array assignments (`key=(v1 v2)`, possibly spanning lines) are split on
// whitespace and unquoted; scalar values may be bare or 'single'/"double"
// quoted. Unknown constructs are ignored.
//
// ParsePKGBUILD returns an error when no usable pkgver declaration is
// found (pkgver is mandatory in a PKGBUILD).
func ParsePKGBUILD(src string) (*SrcInfo, error) {
info := &SrcInfo{}
if v, ok := scalar(src, "pkgbase"); ok {
info.PkgBase = v
}
if names, ok := array(src, "pkgname"); ok {
info.PkgNames = names
} else if v, ok := scalar(src, "pkgname"); ok {
info.PkgNames = []string{v}
}
info.Version, _ = scalar(src, "pkgver")
info.Rel, _ = scalar(src, "pkgrel")
info.Epoch, _ = scalar(src, "epoch")
info.Desc, _ = scalar(src, "pkgdesc")
info.URL, _ = scalar(src, "url")
info.Arch, _ = array(src, "arch")
info.Depends, _ = array(src, "depends")
info.Makedepends, _ = array(src, "makedepends")
info.Optdepends, _ = array(src, "optdepends")
info.Provides, _ = array(src, "provides")
info.Conflicts, _ = array(src, "conflicts")
info.Replaces, _ = array(src, "replaces")
info.Sources, _ = array(src, "source")
info.SHA256Sums, _ = array(src, "sha256sums")
if info.Version == "" {
return nil, errors.New("pkgbuild: missing pkgver")
}
return info, nil
}
// scalar returns the value of a top-level `key=value` scalar assignment.
// The value may be bare, 'single'- or "double"-quoted. An array assignment
// is reported as not-found so callers fall back to array().
func scalar(src, key string) (string, bool) {
m := reScalar[key].FindStringSubmatch(src)
if m == nil {
return "", false
}
value := m[1]
if value == "" {
value = m[2]
}
if value == "" {
value = m[3]
}
value = strings.TrimSpace(value)
if strings.HasPrefix(value, "(") {
return "", false // array form; caller should use array()
}
return value, true
}
// array returns the values of a top-level `key=(v1 v2 ...)` assignment,
// possibly spanning multiple lines. Tokens are split on whitespace and
// surrounding single/double quotes are stripped. Best-effort: quoted values
// containing spaces are not preserved.
func array(src, key string) ([]string, bool) {
m := reArray[key].FindStringSubmatch(src)
if m == nil {
return nil, false
}
tokens := strings.Fields(m[1])
values := make([]string, 0, len(tokens))
for _, tok := range tokens {
values = append(values, stripQuotes(tok))
}
return values, true
}
// stripQuotes removes a matching pair of surrounding single or double
// quotes.
func stripQuotes(s string) string {
if len(s) >= 2 {
if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') {
return s[1 : len(s)-1]
}
}
return s
}
// Load parses the Arch source package at input. If input is a directory it
// prefers <input>/.SRCINFO and falls back to <input>/PKGBUILD; if input is
// a file, its basename selects the parser (.SRCINFO or PKGBUILD). Load
// returns a descriptive error when neither file is found or the file type
// is unknown.
func Load(input string, arch string) (*SrcInfo, error) {
st, err := os.Stat(input)
if err != nil {
return nil, fmt.Errorf("src: %s: %w", input, err)
}
path := input
useSRCINFO := false
if st.IsDir() {
srcinfo := filepath.Join(input, ".SRCINFO")
pkgbuild := filepath.Join(input, "PKGBUILD")
switch {
case fileExists(srcinfo):
path, useSRCINFO = srcinfo, true
case fileExists(pkgbuild):
path = pkgbuild
default:
return nil, fmt.Errorf("src: %s: neither .SRCINFO nor PKGBUILD found", input)
}
} else {
switch filepath.Base(input) {
case ".SRCINFO":
useSRCINFO = true
case "PKGBUILD":
default:
return nil, fmt.Errorf("src: %s: unknown source file (want .SRCINFO or PKGBUILD)", input)
}
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("src: reading %s: %w", path, err)
}
if useSRCINFO {
return ParseSRCINFO(string(data), arch)
}
return ParsePKGBUILD(string(data))
}
// fileExists reports whether path names a regular file.
func fileExists(path string) bool {
st, err := os.Stat(path)
return err == nil && !st.IsDir()
}
+293
View File
@@ -0,0 +1,293 @@
package src
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// srcinfoFixtures is the committed testdata tree, relative to this package
// directory (internal/arch/src).
const srcinfoFixtures = "../../../testdata/srcinfo"
func readFixture(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fixture %s: %v", path, err)
}
return string(b)
}
// TestSrcInfoParseMytoolSRCINFO parses the committed mytool .SRCINFO with
// arch x86_64 and checks every field the converter consumes.
func TestSrcInfoParseMytoolSRCINFO(t *testing.T) {
src := readFixture(t, filepath.Join(srcinfoFixtures, "mytool", ".SRCINFO"))
got, err := ParseSRCINFO(src, "x86_64")
if err != nil {
t.Fatalf("ParseSRCINFO: %v", err)
}
if got.PkgBase != "mytool" {
t.Errorf("PkgBase = %q, want %q", got.PkgBase, "mytool")
}
if !slices.Equal(got.PkgNames, []string{"mytool"}) {
t.Errorf("PkgNames = %v, want [mytool]", got.PkgNames)
}
if got.Version != "1.0" || got.Rel != "1" {
t.Errorf("Version/Rel = %q/%q, want %q/%q", got.Version, got.Rel, "1.0", "1")
}
if got.Desc != "Example custom-build package" {
t.Errorf("Desc = %q, want %q", got.Desc, "Example custom-build package")
}
if got.URL != "https://example.org" {
t.Errorf("URL = %q, want %q", got.URL, "https://example.org")
}
if !slices.Equal(got.Arch, []string{"x86_64"}) {
t.Errorf("Arch = %v, want [x86_64]", got.Arch)
}
if !slices.Equal(got.Depends, []string{"glibc"}) {
t.Errorf("Depends = %v, want [glibc]", got.Depends)
}
if !slices.Equal(got.Sources, []string{"mytool-1.0.tar.gz"}) {
t.Errorf("Sources = %v, want [mytool-1.0.tar.gz]", got.Sources)
}
if !slices.Equal(got.SHA256Sums, []string{"SKIP"}) {
t.Errorf("SHA256Sums = %v, want [SKIP]", got.SHA256Sums)
}
}
// TestSrcInfoParseMytoolPKGBUILD exercises the best-effort bash fallback on
// the same package and expects the essential fields to agree.
func TestSrcInfoParseMytoolPKGBUILD(t *testing.T) {
src := readFixture(t, filepath.Join(srcinfoFixtures, "mytool", "PKGBUILD"))
got, err := ParsePKGBUILD(src)
if err != nil {
t.Fatalf("ParsePKGBUILD: %v", err)
}
if !slices.Equal(got.PkgNames, []string{"mytool"}) {
t.Errorf("PkgNames = %v, want [mytool]", got.PkgNames)
}
if got.Version != "1.0" || got.Rel != "1" {
t.Errorf("Version/Rel = %q/%q, want %q/%q", got.Version, got.Rel, "1.0", "1")
}
if got.Desc != "Example custom-build package" {
t.Errorf("Desc = %q, want %q", got.Desc, "Example custom-build package")
}
if got.URL != "https://example.org" {
t.Errorf("URL = %q, want %q", got.URL, "https://example.org")
}
if !slices.Equal(got.Arch, []string{"x86_64"}) {
t.Errorf("Arch = %v, want [x86_64]", got.Arch)
}
if !slices.Equal(got.Depends, []string{"glibc"}) {
t.Errorf("Depends = %v, want [glibc]", got.Depends)
}
if !slices.Equal(got.Sources, []string{"mytool-1.0.tar.gz"}) {
t.Errorf("Sources = %v, want [mytool-1.0.tar.gz]", got.Sources)
}
if !slices.Equal(got.SHA256Sums, []string{"SKIP"}) {
t.Errorf("SHA256Sums = %v, want [SKIP]", got.SHA256Sums)
}
}
// TestSrcInfoParseSplitPKGBUILD: a split package must PARSE fine (rejection
// happens later in the converter), yielding both pkgnames.
func TestSrcInfoParseSplitPKGBUILD(t *testing.T) {
src := readFixture(t, filepath.Join(srcinfoFixtures, "split", "PKGBUILD"))
got, err := ParsePKGBUILD(src)
if err != nil {
t.Fatalf("ParsePKGBUILD: %v", err)
}
if !slices.Equal(got.PkgNames, []string{"foo", "bar"}) {
t.Errorf("PkgNames = %v, want [foo bar]", got.PkgNames)
}
if got.PkgBase != "foobar" {
t.Errorf("PkgBase = %q, want %q", got.PkgBase, "foobar")
}
if got.Version != "1.0" {
t.Errorf("Version = %q, want %q", got.Version, "1.0")
}
}
// TestSrcInfoParseVCSPKGBUILD: VCS sources must come through verbatim so the
// converter can detect and reject them with a clear message.
func TestSrcInfoParseVCSPKGBUILD(t *testing.T) {
src := readFixture(t, filepath.Join(srcinfoFixtures, "vcs", "PKGBUILD"))
got, err := ParsePKGBUILD(src)
if err != nil {
t.Fatalf("ParsePKGBUILD: %v", err)
}
if !slices.Equal(got.PkgNames, []string{"mygit"}) {
t.Errorf("PkgNames = %v, want [mygit]", got.PkgNames)
}
if !slices.Contains(got.Sources, "git+https://example.org/repo.git") {
t.Errorf("Sources = %v, want it to contain %q", got.Sources, "git+https://example.org/repo.git")
}
}
// TestSrcInfoArchFilter feeds a .SRCINFO with arch-specific keys and checks
// that only the requested arch's variants are folded in.
func TestSrcInfoArchFilter(t *testing.T) {
src := `pkgbase = demo
pkgver = 2.0
pkgrel = 3
epoch = 1
depends = bash
depends_x86_64 = zsh
depends_aarch64 = sh
source_x86_64 = demo-2.0.tar.gz
source_aarch64 = demo-2.0-aarch64.tar.gz
sha256sums_x86_64 = SKIP
sha256sums_aarch64 = DEADBEEF
pkgname = demo
`
got, err := ParseSRCINFO(src, "x86_64")
if err != nil {
t.Fatalf("ParseSRCINFO(x86_64): %v", err)
}
if !slices.Equal(got.Depends, []string{"bash", "zsh"}) {
t.Errorf("Depends(x86_64) = %v, want [bash zsh] (aarch64 variant must NOT leak in)", got.Depends)
}
if !slices.Equal(got.Sources, []string{"demo-2.0.tar.gz"}) {
t.Errorf("Sources(x86_64) = %v, want [demo-2.0.tar.gz]", got.Sources)
}
if !slices.Equal(got.SHA256Sums, []string{"SKIP"}) {
t.Errorf("SHA256Sums(x86_64) = %v, want [SKIP]", got.SHA256Sums)
}
if got.Version != "2.0" || got.Rel != "3" || got.Epoch != "1" {
t.Errorf("Version/Rel/Epoch = %q/%q/%q, want %q/%q/%q", got.Version, got.Rel, got.Epoch, "2.0", "3", "1")
}
got, err = ParseSRCINFO(src, "aarch64")
if err != nil {
t.Fatalf("ParseSRCINFO(aarch64): %v", err)
}
if !slices.Equal(got.Depends, []string{"bash", "sh"}) {
t.Errorf("Depends(aarch64) = %v, want [bash sh]", got.Depends)
}
if !slices.Equal(got.Sources, []string{"demo-2.0-aarch64.tar.gz"}) {
t.Errorf("Sources(aarch64) = %v, want [demo-2.0-aarch64.tar.gz]", got.Sources)
}
if !slices.Equal(got.SHA256Sums, []string{"DEADBEEF"}) {
t.Errorf("SHA256Sums(aarch64) = %v, want [DEADBEEF]", got.SHA256Sums)
}
}
// TestSrcInfoMultiPkgnameSRCINFO: multiple pkgname sections must not fail
// parsing; every name is collected, only the FIRST section's keys apply.
func TestSrcInfoMultiPkgnameSRCINFO(t *testing.T) {
src := `pkgbase = split
pkgver = 1.0
pkgrel = 1
pkgname = foo
depends = a
pkgname = bar
depends = b
`
got, err := ParseSRCINFO(src, "x86_64")
if err != nil {
t.Fatalf("ParseSRCINFO: %v", err)
}
if !slices.Equal(got.PkgNames, []string{"foo", "bar"}) {
t.Errorf("PkgNames = %v, want [foo bar]", got.PkgNames)
}
if !slices.Equal(got.Depends, []string{"a"}) {
t.Errorf("Depends = %v, want [a] (only the first pkgname section applies)", got.Depends)
}
}
// TestSrcInfoPKGBUILDMissingPkgver: a PKGBUILD without a usable pkgver
// declaration is malformed and must error.
func TestSrcInfoPKGBUILDMissingPkgver(t *testing.T) {
src := `pkgname=foo
pkgrel=1
pkgdesc="no version here"
arch=(x86_64)
depends=('glibc')
`
got, err := ParsePKGBUILD(src)
if err == nil {
t.Fatalf("ParsePKGBUILD = %+v, want error for missing pkgver", got)
}
if !strings.Contains(err.Error(), "pkgver") {
t.Errorf("error %q does not mention pkgver", err)
}
}
// TestSrcInfoLoadDirPrefersSRCINFO: loading a directory uses .SRCINFO first
// (PkgBase is only present in the .SRCINFO fixture, not the PKGBUILD).
func TestSrcInfoLoadDirPrefersSRCINFO(t *testing.T) {
got, err := Load(filepath.Join(srcinfoFixtures, "mytool"), "x86_64")
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.PkgBase != "mytool" {
t.Errorf("PkgBase = %q, want %q (proves .SRCINFO was preferred over PKGBUILD)", got.PkgBase, "mytool")
}
if got.Version != "1.0" {
t.Errorf("Version = %q, want %q", got.Version, "1.0")
}
}
// TestSrcInfoLoadDirFallsBackToPKGBUILD: a directory with only a PKGBUILD
// falls back to the best-effort parser.
func TestSrcInfoLoadDirFallsBackToPKGBUILD(t *testing.T) {
got, err := Load(filepath.Join(srcinfoFixtures, "split"), "x86_64")
if err != nil {
t.Fatalf("Load: %v", err)
}
if !slices.Equal(got.PkgNames, []string{"foo", "bar"}) {
t.Errorf("PkgNames = %v, want [foo bar]", got.PkgNames)
}
}
// TestSrcInfoLoadFilePKGBUILD: loading a file named PKGBUILD parses it as
// such regardless of directory.
func TestSrcInfoLoadFilePKGBUILD(t *testing.T) {
got, err := Load(filepath.Join(srcinfoFixtures, "vcs", "PKGBUILD"), "x86_64")
if err != nil {
t.Fatalf("Load: %v", err)
}
if !slices.Equal(got.PkgNames, []string{"mygit"}) {
t.Errorf("PkgNames = %v, want [mygit]", got.PkgNames)
}
}
// TestSrcInfoLoadFileSRCINFO: loading a file named .SRCINFO parses it as
// such.
func TestSrcInfoLoadFileSRCINFO(t *testing.T) {
got, err := Load(filepath.Join(srcinfoFixtures, "mytool", ".SRCINFO"), "x86_64")
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.PkgBase != "mytool" {
t.Errorf("PkgBase = %q, want %q", got.PkgBase, "mytool")
}
}
// TestSrcInfoLoadUnknownFile: a file that is neither .SRCINFO nor PKGBUILD
// is rejected with a clear error.
func TestSrcInfoLoadUnknownFile(t *testing.T) {
dir := t.TempDir()
other := filepath.Join(dir, "notes.txt")
if err := os.WriteFile(other, []byte("pkgname=foo\npkgver=1\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, err := Load(other, "x86_64")
if err == nil {
t.Fatalf("Load(%s) = nil error, want unknown-file error", other)
}
if !strings.Contains(err.Error(), "unknown") {
t.Errorf("error %q does not mention the unknown file type", err)
}
}
// TestSrcInfoLoadMissingDir: a nonexistent path errors clearly.
func TestSrcInfoLoadMissingDir(t *testing.T) {
_, err := Load(filepath.Join(t.TempDir(), "does-not-exist"), "x86_64")
if err == nil {
t.Fatalf("Load(nonexistent) = nil error, want error")
}
}