diff --git a/internal/arch/binary/pkginfo.go b/internal/arch/binary/pkginfo.go new file mode 100644 index 0000000..1a3d6de --- /dev/null +++ b/internal/arch/binary/pkginfo.go @@ -0,0 +1,74 @@ +package binary + +import ( + "errors" + "strings" +) + +// PKGInfo is the parsed content of an ALPM .PKGINFO file, the metadata +// member at the root of an Arch .pkg.tar.zst package. +type PKGInfo struct { + Name, Version, Desc, URL, Arch string + Depends, OptDepends, Provides, Conflicts, Replaces []string +} + +// Parse parses ALPM .PKGINFO content: lines of "key = value", full-line +// `#` comments and blank lines ignored. Multi-value keys (depend, +// optdepend, provides, conflicts, replaces, license) accumulate across +// lines; single-value keys (pkgname, pkgver, pkgdesc, url, arch, pkgbase) +// take the last occurrence. pkgver is the FULL version, already in +// epoch+pkgrel form (e.g. "1.0-1" or "1:1.0.0-1"), and is passed through +// verbatim. Unknown keys are ignored. Malformed lines (no '=') are +// reported through warn and skipped, not fatal. +// +// Parse returns an error if pkgname is missing: a .PKGINFO without a name +// is invalid for conversion. +func Parse(src string, warn func(string)) (*PKGInfo, error) { + info := &PKGInfo{} + haveName := false + for _, line := range strings.Split(src, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + eq := strings.Index(line, "=") + if eq < 0 { + if warn != nil { + warn("malformed .PKGINFO line (no '='): " + line) + } + continue + } + key := strings.TrimSpace(line[:eq]) + value := strings.TrimSpace(line[eq+1:]) + switch key { + case "pkgname": + info.Name = value + haveName = true + case "pkgver": + info.Version = value + case "pkgdesc": + info.Desc = value + case "url": + info.URL = value + case "arch": + info.Arch = value + case "depend": + info.Depends = append(info.Depends, value) + case "optdepend": + 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) + default: + // pkgbase, license, builddate, packager, size, xdata and any + // unknown keys are not represented in PKGInfo; ignore them. + } + } + if !haveName { + return nil, errors.New("pkginfo: missing pkgname") + } + return info, nil +} diff --git a/internal/arch/binary/pkginfo_test.go b/internal/arch/binary/pkginfo_test.go new file mode 100644 index 0000000..3bc3fb3 --- /dev/null +++ b/internal/arch/binary/pkginfo_test.go @@ -0,0 +1,135 @@ +package binary + +import ( + "os" + "reflect" + "strings" + "testing" +) + +// readFixture loads a committed .PKGINFO fixture relative to the repo root. +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) +} + +func TestPKGInfo(t *testing.T) { + tests := []struct { + name string + src string + want *PKGInfo + }{ + { + name: "hello fixture", + src: readFixture(t, "../../../testdata/pkginfo/hello.PKGINFO"), + want: &PKGInfo{ + Name: "hello", + Version: "1.0-1", + Desc: "A tiny demonstration program", + URL: "https://example.org", + Arch: "x86_64", + Depends: []string{"glibc", "zlib"}, + }, + }, + { + name: "multi fixture", + src: readFixture(t, "../../../testdata/pkginfo/multi.PKGINFO"), + want: &PKGInfo{ + Name: "multiload", + Version: "2.3-1", + Desc: "Multi dep test", + URL: "https://example.org", + Arch: "x86_64", + Depends: []string{"glibc", "pcre2>=10.42"}, + OptDepends: []string{"python: for scripting"}, + Provides: []string{"multiload"}, + }, + }, + { + name: "epoch fixture keeps colon", + src: readFixture(t, "../../../testdata/pkginfo/epoch.PKGINFO"), + want: &PKGInfo{ + Name: "epochtool", + Version: "1:1.0.0-1", + Desc: "Epoch test", + URL: "https://example.org", + Arch: "x86_64", + Depends: []string{"glibc"}, + }, + }, + { + name: "comments blank lines unknown keys and whitespace", + src: strings.Join([]string{ + "# full-line comment", + "", + " pkgname = spaced ", + "pkgver = 9.9-9", + "pkgbase = ignored-base", + "license = GPL2", + "license = MIT", + "builddate = 1724000000", + "unknown = whatever", + "conflicts = oldtool", + "replaces = replaced", + }, "\n") + "\n", + want: &PKGInfo{ + Name: "spaced", + Version: "9.9-9", + Conflicts: []string{"oldtool"}, + Replaces: []string{"replaced"}, + }, + }, + { + name: "last occurrence wins for single-value keys", + src: "pkgname = first\npkgver = 1.0-1\npkgname = second\npkgver = 2.0-2\n", + want: &PKGInfo{ + Name: "second", + Version: "2.0-2", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Parse(tt.src, func(msg string) { + t.Errorf("unexpected warn: %s", msg) + }) + if err != nil { + t.Fatalf("Parse() error = %v, want nil", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Parse() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestPKGInfoMalformedLineWarns(t *testing.T) { + src := "pkgname = ok\npkgver = 1.0-1\ngarbage no equals\n" + var warns []string + info, err := Parse(src, func(msg string) { + warns = append(warns, msg) + }) + if err != nil { + t.Fatalf("Parse() error = %v, want nil", err) + } + if len(warns) != 1 { + t.Fatalf("warn called %d times, want exactly 1: %v", len(warns), warns) + } + if !strings.Contains(warns[0], "garbage no equals") { + t.Errorf("warn message %q does not mention the malformed line", warns[0]) + } + if info.Name != "ok" || info.Version != "1.0-1" { + t.Errorf("Parse() = %#v, want name ok / version 1.0-1 despite malformed line", info) + } +} + +func TestPKGInfoMissingName(t *testing.T) { + _, err := Parse("pkgver = 1.0-1\n", func(string) {}) + if err == nil { + t.Fatal("Parse() error = nil, want error for missing pkgname") + } +}