From 44c63b5cf72f620267d772789cd0d3435fd0afce Mon Sep 17 00:00:00 2001 From: huntedbytheirs Date: Wed, 19 Aug 2026 18:31:59 -0400 Subject: [PATCH] feat(arch/src): .SRCINFO parser + PKGBUILD fallback Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- internal/arch/src/srcinfo.go | 299 ++++++++++++++++++++++++++++++ internal/arch/src/srcinfo_test.go | 293 +++++++++++++++++++++++++++++ 2 files changed, 592 insertions(+) create mode 100644 internal/arch/src/srcinfo.go create mode 100644 internal/arch/src/srcinfo_test.go diff --git a/internal/arch/src/srcinfo.go b/internal/arch/src/srcinfo.go new file mode 100644 index 0000000..a7cfbf7 --- /dev/null +++ b/internal/arch/src/srcinfo.go @@ -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 _ (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 _ 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 _ 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_` key applies only when matches; a +// `key_` 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 /.SRCINFO and falls back to /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() +} diff --git a/internal/arch/src/srcinfo_test.go b/internal/arch/src/srcinfo_test.go new file mode 100644 index 0000000..ad0ecaf --- /dev/null +++ b/internal/arch/src/srcinfo_test.go @@ -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") + } +}